Kanban

A generic, drag-and-drop kanban board whose columns, card fields, and search are driven by your typed data model.

npx shadcn@latest add https://shuip.plvo.dev/r/kanban.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/kanban.json
bun x shadcn@latest add https://shuip.plvo.dev/r/kanban.json
'use client';
import {
closestCorners,
DndContext,
type DragEndEvent,
type DragOverEvent,
DragOverlay,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Plus, Search } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
export type KanbanColumn = {
id: string;
label: string;
color?: string;
};
export type KanbanField<T> = {
key: keyof T;
label?: string;
render?: (value: T[keyof T], item: T) => React.ReactNode;
};
export type KanbanProps<T extends Record<string, unknown>> = {
columns: KanbanColumn[];
data?: T[];
defaultData?: T[];
onDataChange?: (next: T[]) => void;
idField?: keyof T;
columnField: keyof T;
title?: (item: T) => React.ReactNode;
fields?: KanbanField<T>[];
cardContent?: (item: T) => React.ReactNode;
searchableFields?: (keyof T)[];
searchPlaceholder?: string;
renderColumnSummary?: (items: T[], column: KanbanColumn) => React.ReactNode;
onCardClick?: (item: T) => void;
onCardAdd?: (columnId: string) => void;
onCardMove?: (e: { item: T; fromColumn: string; toColumn: string; toIndex: number }) => void;
className?: string;
};
export function Kanban<T extends Record<string, unknown>>({
columns,
data,
defaultData,
onDataChange,
idField = 'id' as keyof T,
columnField,
title,
fields,
cardContent,
searchableFields,
searchPlaceholder = 'Search...',
renderColumnSummary,
onCardClick,
onCardAdd,
onCardMove,
className,
}: KanbanProps<T>) {
const getId = React.useCallback((item: T) => String(item[idField]), [idField]);
const getColumn = React.useCallback((item: T) => String(item[columnField]), [columnField]);
const [items, setItems] = React.useState<T[]>(() => data ?? defaultData ?? []);
const draggingRef = React.useRef(false);
const fromColumnRef = React.useRef<string | null>(null);
const fromIndexRef = React.useRef<number | null>(null);
const startItemsRef = React.useRef<T[] | null>(null);
React.useEffect(() => {
if (data && !draggingRef.current) setItems(data);
}, [data]);
const [query, setQuery] = React.useState('');
const deferredQuery = React.useDeferredValue(query);
const [activeId, setActiveId] = React.useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const visible = React.useMemo(() => {
const q = deferredQuery.trim().toLowerCase();
if (!q || !searchableFields?.length) return items;
return items.filter((it) =>
searchableFields.some((f) =>
String(it[f] ?? '')
.toLowerCase()
.includes(q),
),
);
}, [items, deferredQuery, searchableFields]);
const itemsByColumn = React.useMemo(() => {
const grouped = new Map<string, T[]>(columns.map((column) => [column.id, []]));
for (const item of visible) {
grouped.get(getColumn(item))?.push(item);
}
return grouped;
}, [columns, visible, getColumn]);
const columnIndexOf = React.useCallback(
(list: T[], columnId: string, cardId: string) =>
list.filter((it) => getColumn(it) === columnId).findIndex((it) => getId(it) === cardId),
[getColumn, getId],
);
function handleDragStart(event: DragStartEvent) {
draggingRef.current = true;
const id = String(event.active.id);
const startItem = items.find((it) => getId(it) === id);
const startColumn = startItem ? getColumn(startItem) : null;
fromColumnRef.current = startColumn;
fromIndexRef.current = startColumn == null ? null : columnIndexOf(items, startColumn, id);
startItemsRef.current = items;
setActiveId(id);
}
// Reorder live so the rendered list always matches the drag preview; the drop
// handler only reads the settled order and notifies. This keeps intra- and
// cross-column moves consistent (no off-by-one between preview and commit).
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeCardId = String(active.id);
const overId = String(over.id);
if (activeCardId === overId) return;
setItems((prev) => {
const activeIndex = prev.findIndex((it) => getId(it) === activeCardId);
if (activeIndex < 0) return prev;
const overIsColumn = columns.some((c) => c.id === overId);
const overItem = overIsColumn ? undefined : prev.find((it) => getId(it) === overId);
const overColumn = overIsColumn ? overId : overItem ? getColumn(overItem) : null;
if (overColumn == null) return prev;
const columnChanged = getColumn(prev[activeIndex]) !== overColumn;
const updated = columnChanged
? prev.map((it, i) => (i === activeIndex ? ({ ...it, [columnField]: overColumn } as T) : it))
: prev;
const activeIndexNow = columnChanged ? updated.findIndex((it) => getId(it) === activeCardId) : activeIndex;
let overIndex = activeIndexNow;
if (overIsColumn) {
for (let i = 0; i < updated.length; i++) {
if (getId(updated[i]) !== activeCardId && getColumn(updated[i]) === overColumn) overIndex = i;
}
} else {
overIndex = updated.findIndex((it) => getId(it) === overId);
}
if (overIndex < 0 || (!columnChanged && overIndex === activeIndexNow)) return updated;
return arrayMove(updated, activeIndexNow, overIndex);
});
}
function handleDragEnd(event: DragEndEvent) {
draggingRef.current = false;
setActiveId(null);
const fromColumn = fromColumnRef.current;
const fromIndex = fromIndexRef.current;
fromColumnRef.current = null;
fromIndexRef.current = null;
if (fromColumn == null) return;
const activeCardId = String(event.active.id);
const movedItem = items.find((it) => getId(it) === activeCardId);
if (!movedItem) return;
const toColumn = getColumn(movedItem);
const toIndex = columnIndexOf(items, toColumn, activeCardId);
startItemsRef.current = null;
if (toColumn === fromColumn && toIndex === fromIndex) return;
onDataChange?.(items);
onCardMove?.({ item: movedItem, fromColumn, toColumn, toIndex });
}
function handleDragCancel() {
draggingRef.current = false;
const snapshot = startItemsRef.current;
startItemsRef.current = null;
fromColumnRef.current = null;
fromIndexRef.current = null;
setActiveId(null);
if (snapshot) setItems(snapshot);
}
const activeItem = activeId ? (items.find((it) => getId(it) === activeId) ?? null) : null;
const renderTitle = React.useCallback((item: T): React.ReactNode => (title ? title(item) : null), [title]);
const renderBody = React.useCallback(
(item: T): React.ReactNode => {
if (cardContent) return cardContent(item);
if (!fields?.length) return null;
return (
<div className='flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground'>
{fields.map((field) => {
const value = item[field.key];
return (
<span key={String(field.key)} className='whitespace-nowrap'>
{field.label ? <span className='font-medium'>{field.label}: </span> : null}
{field.render ? field.render(value, item) : String(value ?? '')}
</span>
);
})}
</div>
);
},
[cardContent, fields],
);
return (
<div className={cn('flex flex-col gap-4', className)}>
{searchableFields?.length ? (
<div className='relative w-full max-w-xs'>
<Search className='absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className='pl-8'
/>
</div>
) : null}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className='flex flex-row gap-4 overflow-x-auto pb-2'>
{columns.map((column) => {
const columnItems = itemsByColumn.get(column.id) ?? [];
return (
<KanbanColumnView
key={column.id}
column={column}
count={columnItems.length}
summary={renderColumnSummary?.(columnItems, column)}
onAdd={onCardAdd ? () => onCardAdd(column.id) : undefined}
>
<SortableContext items={columnItems.map((it) => getId(it))} strategy={verticalListSortingStrategy}>
{columnItems.length === 0 ? (
<p className='rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground'>
No items
</p>
) : (
columnItems.map((item) => (
<SortableCard
key={getId(item)}
id={getId(item)}
columnId={column.id}
title={renderTitle(item)}
body={renderBody(item)}
onClick={onCardClick ? () => onCardClick(item) : undefined}
/>
))
)}
</SortableContext>
</KanbanColumnView>
);
})}
</div>
<DragOverlay>
{activeItem ? (
<KanbanCardFace
title={renderTitle(activeItem)}
body={renderBody(activeItem)}
className='rotate-3 shadow-lg'
handle={<GripVertical className='mt-0.5 size-4 shrink-0 text-muted-foreground' />}
/>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}
function KanbanColumnView({
column,
count,
summary,
onAdd,
children,
}: {
column: KanbanColumn;
count: number;
summary?: React.ReactNode;
onAdd?: () => void;
children: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id: column.id });
return (
<div className='flex w-72 shrink-0 flex-col gap-3'>
<div className='flex items-center gap-2'>
{column.color ? <span className='size-2 rounded-full' style={{ backgroundColor: column.color }} /> : null}
<span className='text-sm font-medium'>{column.label}</span>
<span className='rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground'>{count}</span>
{summary != null ? <span className='ml-auto text-xs text-muted-foreground'>{summary}</span> : null}
{onAdd ? (
<Button variant='ghost' size='icon' className={cn('size-6', summary == null && 'ml-auto')} onClick={onAdd}>
<Plus className='size-4' />
</Button>
) : null}
</div>
<div
ref={setNodeRef}
className={cn(
'flex min-h-24 flex-col gap-2 rounded-lg border border-transparent p-2 transition-colors',
isOver && 'border-border bg-muted/50',
)}
>
{children}
</div>
</div>
);
}
function SortableCard({
id,
columnId,
title,
body,
onClick,
}: {
id: string;
columnId: string;
title?: React.ReactNode;
body?: React.ReactNode;
onClick?: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
data: { columnId },
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className={cn(isDragging && 'opacity-50')}>
<KanbanCardFace
title={title}
body={body}
onClick={onClick}
handle={
<button
type='button'
className='mt-0.5 cursor-grab touch-none text-muted-foreground active:cursor-grabbing'
onClick={(e) => e.stopPropagation()}
{...attributes}
{...listeners}
>
<GripVertical className='size-4' />
</button>
}
/>
</div>
);
}
function KanbanCardFace({
title,
body,
onClick,
handle,
className,
}: {
title?: React.ReactNode;
body?: React.ReactNode;
onClick?: () => void;
handle?: React.ReactNode;
className?: string;
}) {
const hasBody = body != null;
return (
<Card className={cn('gap-0 py-0', onClick && 'cursor-pointer', className)} onClick={onClick}>
<CardHeader className={cn('flex flex-row items-start gap-2 space-y-0 px-3 pt-3', hasBody ? 'pb-0' : 'pb-3')}>
{handle}
{title != null ? (
<CardTitle className='flex-1 text-sm leading-snug'>{title}</CardTitle>
) : (
<span className='flex-1' />
)}
</CardHeader>
{hasBody ? <CardContent className='px-3 pb-3 pl-9 pt-1'>{body}</CardContent> : null}
</Card>
);
}
Loading...

Kanban is a generic board (Kanban<T>) that renders any array of typed items into draggable columns. Columns, the property fields shown on each card, and the searchable fields are all configured against your own data shape — nothing is hardcoded to a domain.

Built-in features

  • Generic data model: works with any item type T via idField / columnField.
  • Structured cards: the title is rendered from title(item) (as a CardTitle, so it can be a string, a link, or any element); the content area is driven by a fields config or fully customized with cardContent.
  • Inter and intra-column drag-and-drop: move cards between columns and reorder within a column.
  • Hybrid state: works standalone from defaultData, or controlled via data + onDataChange.
  • Global search: filter cards across searchableFields.
  • Per-column extras: count, a renderColumnSummary slot, color accent, and an add button.

The card order is implicit in the data array order. On any move, the board emits the reordered array via onDataChange (and a semantic onCardMove event); map that to your own persistence.

Define your item type with a type alias, not an interfaceKanban<T> requires T extends Record<string, unknown>, which a TypeScript interface does not satisfy.

Examples

Custom Card

Loading...
'use client';
import * as React from 'react';
import { Kanban } from '@/components/block/shuip/kanban';
type Deal = {
id: string;
name: string;
company: string;
value: number;
stage: string;
};
const columns = [
{ id: 'lead', label: 'Lead', color: 'var(--color-muted-foreground)' },
{ id: 'qualified', label: 'Qualified', color: 'var(--color-blue-500)' },
{ id: 'negotiation', label: 'Negotiation', color: 'var(--color-amber-500)' },
{ id: 'won', label: 'Won', color: 'var(--color-green-500)' },
];
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value);
const initialDeals: Deal[] = [
{ id: 'd1', name: 'Acme website', company: 'Acme Inc', value: 12000, stage: 'lead' },
{ id: 'd2', name: 'Globex platform', company: 'Globex', value: 48000, stage: 'qualified' },
{ id: 'd3', name: 'Initech migration', company: 'Initech', value: 30000, stage: 'qualified' },
{ id: 'd4', name: 'Umbrella retainer', company: 'Umbrella', value: 60000, stage: 'negotiation' },
{ id: 'd5', name: 'Soylent rebrand', company: 'Soylent', value: 18000, stage: 'won' },
];
export default function KanbanCustomCardExample() {
const [deals, setDeals] = React.useState<Deal[]>(initialDeals);
return (
<Kanban<Deal>
columns={columns}
data={deals}
onDataChange={setDeals}
columnField='stage'
title={(deal) => (
<a href={`#deal-${deal.id}`} className='hover:underline' onClick={(e) => e.stopPropagation()}>
{deal.name}
</a>
)}
cardContent={(deal) => (
<div className='space-y-1 text-xs'>
<p className='truncate text-muted-foreground'>{deal.company}</p>
<p className='font-mono'>{formatCurrency(deal.value)}</p>
</div>
)}
renderColumnSummary={(items) => formatCurrency(items.reduce((sum, d) => sum + d.value, 0))}
onCardMove={(e) => console.log(`${e.item.name}: ${e.fromColumn} -> ${e.toColumn} @ ${e.toIndex}`)}
/>
);
}

Default

Loading...
'use client';
import { Kanban } from '@/components/block/shuip/kanban';
type Task = {
id: string;
title: string;
assignee: string;
priority: 'low' | 'medium' | 'high';
points: number;
status: string;
};
const columns = [
{ id: 'todo', label: 'To do', color: 'var(--color-muted-foreground)' },
{ id: 'in-progress', label: 'In progress', color: 'var(--color-blue-500)' },
{ id: 'done', label: 'Done', color: 'var(--color-green-500)' },
];
const tasks: Task[] = [
{ id: '1', title: 'Design the empty states', assignee: 'Ava', priority: 'medium', points: 3, status: 'todo' },
{ id: '2', title: 'Wire the search bar', assignee: 'Liam', priority: 'high', points: 5, status: 'todo' },
{ id: '3', title: 'Drag-and-drop sensors', assignee: 'Noah', priority: 'high', points: 8, status: 'in-progress' },
{ id: '4', title: 'Column color accents', assignee: 'Mia', priority: 'low', points: 2, status: 'in-progress' },
{ id: '5', title: 'Write the docs page', assignee: 'Ava', priority: 'medium', points: 3, status: 'done' },
];
export default function KanbanDefaultExample() {
return (
<Kanban<Task>
columns={columns}
defaultData={tasks}
columnField='status'
title={(task) => task.title}
fields={[
{ key: 'assignee', label: 'Assignee' },
{ key: 'priority', label: 'Priority' },
{ key: 'points', render: (value) => `${String(value)} pts` },
]}
searchableFields={['title', 'assignee']}
searchPlaceholder='Search tasks...'
renderColumnSummary={(items) => `${items.reduce((sum, t) => sum + t.points, 0)} pts`}
onCardClick={(task) => console.log('open task', task.id)}
onCardAdd={(columnId) => console.log('add card to', columnId)}
/>
);
}

Props

Prop

Type

On this page