A multi-column kanban board with issue cards showing title, labels, assignee avatar, and priority indicator. Cards support HTML5 drag-and-drop reordering within and across columns, with an 'Add card' affordance per column.
Fix sparkline overflow on mobile
Add empty state to notification center
Editable cell keyboard navigation
Ship kanban drag reorder
Drag a card to reorder within or across columns · “Add card” appends a new issue
// Dependencies: react ^18, lucide-react
import React, { useState } from 'react';
import { Plus } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
export type KanbanLabel = { id: string; name: string; color: string };
export type KanbanCard = {
id: string;
title: string;
labels?: KanbanLabel[];
assignee?: { name: string; initials: string; color: string };
priority?: 'urgent' | 'high' | 'medium' | 'low';
};
export type KanbanColumnData = {
id: string;
title: string;
cards: KanbanCard[];
};
type Props = {
initialColumns: KanbanColumnData[];
};
const PRIORITY_COLOR: Record<string, string> = {
urgent: '#ef4444',
high: '#f97316',
medium: '#eab308',
low: '#3b82f6',
};
let nextCardId = 0;
export function KanbanBoard({ initialColumns }: Props) {
const [columns, setColumns] = useState<KanbanColumnData[]>(initialColumns);
const [dragCard, setDragCard] = useState<{ colId: string; cardId: string } | null>(null);
const addCard = (colId: string) => {
setColumns((prev) =>
prev.map((col) =>
col.id === colId
? {
...col,
cards: [
...col.cards,
{ id: `new-${Date.now()}-${++nextCardId}`, title: 'New issue' },
],
}
: col
)
);
};
const handleDrop = (colId: string, targetCardId: string | null) => {
if (!dragCard) return;
setColumns((prev) => {
const sourceCol = prev.find((c) => c.id === dragCard.colId);
const draggedCard = sourceCol?.cards.find((c) => c.id === dragCard.cardId);
if (!sourceCol || !draggedCard) return prev;
let next = prev.map((col) =>
col.id === sourceCol.id
? { ...col, cards: col.cards.filter((c) => c.id !== draggedCard.id) }
: col
);
next = next.map((col) => {
if (col.id !== colId) return col;
const cards = [...col.cards];
const targetIndex = targetCardId
? cards.findIndex((c) => c.id === targetCardId)
: cards.length;
const insertAt = targetIndex === -1 ? cards.length : targetIndex;
cards.splice(insertAt, 0, draggedCard);
return { ...col, cards };
});
return next;
});
setDragCard(null);
};
return (
<div className="w-full flex gap-3 overflow-x-auto">
{columns.map((col) => (
<div
key={col.id}
className="w-60 shrink-0 flex flex-col bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl overflow-hidden"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
handleDrop(col.id, null);
}}
>
{/* Column header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-[#1a1a1a]">
<span className="text-xs font-semibold text-zinc-300">{col.title}</span>
<span className="text-[11px] text-zinc-600 bg-[#151515] rounded-full px-1.5 py-0.5">
{col.cards.length}
</span>
</div>
{/* Cards */}
<div className="flex-1 flex flex-col gap-2 p-2 min-h-[60px]">
{col.cards.map((card) => (
<div
key={card.id}
draggable
onDragStart={() => setDragCard({ colId: col.id, cardId: card.id })}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
handleDrop(col.id, card.id);
}}
className={cn(
'rounded-lg border border-[#1e1e1e] bg-[#111] p-2.5 flex flex-col gap-2 cursor-grab active:cursor-grabbing hover:border-[#2a2a2a] transition-colors',
dragCard?.cardId === card.id && 'opacity-40'
)}
>
<p className="text-[13px] text-zinc-200 leading-snug">{card.title}</p>
{card.labels && card.labels.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{card.labels.map((label) => (
<span
key={label.id}
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium border"
style={{
color: label.color,
borderColor: `${label.color}40`,
backgroundColor: `${label.color}15`,
}}
>
{label.name}
</span>
))}
</div>
)}
<div className="flex items-center justify-between">
{card.priority ? (
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: PRIORITY_COLOR[card.priority] }}
/>
) : (
<span />
)}
{card.assignee && (
<div
className="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
style={{ backgroundColor: card.assignee.color }}
title={card.assignee.name}
>
{card.assignee.initials}
</div>
)}
</div>
</div>
))}
</div>
{/* Add card */}
<button
onClick={() => addCard(col.id)}
className="flex items-center gap-1.5 px-3 py-2.5 border-t border-[#1a1a1a] text-xs text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.03] transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add card
</button>
</div>
))}
</div>
);
}
// Usage:
// const COLUMNS = [
// {
// id: 'todo',
// title: 'Todo',
// cards: [
// { id: '1', title: 'Fix sparkline overflow', priority: 'high', assignee: { name: 'Sarah Chen', initials: 'SC', color: '#6366f1' } },
// ],
// },
// { id: 'in-progress', title: 'In Progress', cards: [] },
// { id: 'done', title: 'Done', cards: [] },
// ];
// <KanbanBoard initialColumns={COLUMNS} />Unlock to copy
Free access to all patterns
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Sparkline Trend Cards
Linear
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Bottom Sheet
Stripe
Toast / Snackbar Stack
Vercel
Notification Center
Linear
Issue / Task Card
Linear
Subtask Checklist
Linear
Kanban Board Column
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
File Upload Dropzone
Vercel
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Row Detail Drawer
Stripe
Editable Cell
Notion
Status Badge System
Linear
Segmented Control
Notion