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' /><Inputvalue={query}onChange={(e) => setQuery(e.target.value)}placeholder={searchPlaceholder}className='pl-8'/></div>) : null}<DndContextsensors={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 (<KanbanColumnViewkey={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) => (<SortableCardkey={getId(item)}id={getId(item)}columnId={column.id}title={renderTitle(item)}body={renderBody(item)}onClick={onCardClick ? () => onCardClick(item) : undefined}/>)))}</SortableContext></KanbanColumnView>);})}</div><DragOverlay>{activeItem ? (<KanbanCardFacetitle={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><divref={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')}><KanbanCardFacetitle={title}body={body}onClick={onClick}handle={<buttontype='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>);}
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
TviaidField/columnField. - Structured cards: the title is rendered from
title(item)(as aCardTitle, so it can be a string, a link, or any element); the content area is driven by afieldsconfig or fully customized withcardContent. - Inter and intra-column drag-and-drop: move cards between columns and reorder within a column.
- Hybrid state: works standalone from
defaultData, or controlled viadata+onDataChange. - Global search: filter cards across
searchableFields. - Per-column extras: count, a
renderColumnSummaryslot, color accent, and an add button.
The card order is implicit in the
dataarray order. On any move, the board emits the reordered array viaonDataChange(and a semanticonCardMoveevent); map that to your own persistence.
Define your item type with a
typealias, not aninterface—Kanban<T>requiresT extends Record<string, unknown>, which a TypeScriptinterfacedoes not satisfy.
Examples
Custom Card
'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
'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