Kanban
import { GripVertical } from "lucide-solid";import { createSignal, For, Show } from "solid-js";import { Kanban, KanbanBoard, KanbanColumn, KanbanColumnContent, KanbanColumnHandle, KanbanItem, KanbanItemHandle, KanbanOverlay,} from "@/registry/kobalte/blocks/kanban";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Badge } from "~/components/ui/badge";import { Button } from "~/components/ui/button";import { Card, CardContent, CardHeader } from "~/components/ui/card";
type Task = { id: string; title: string; priority: "low" | "medium" | "high"; assignee?: string; assigneeAvatar?: string; dueDate?: string;};
const COLUMN_TITLES: Record<string, string> = { backlog: "Backlog", inProgress: "In Progress", review: "Review", done: "Done",};
function priorityVariant(priority: Task["priority"]) { if (priority === "high") return "destructive" as const; if (priority === "medium") return "default" as const; return "secondary" as const;}
function TaskCardContent(props: { task: Task }) { return ( <Card> <CardContent class="space-y-2.5 px-3"> <div class="flex items-start justify-between gap-2"> <span class="min-w-0 flex-1 line-clamp-2 font-medium text-sm">{props.task.title}</span> <Badge variant={priorityVariant(props.task.priority)} class="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-xs capitalize" > {props.task.priority} </Badge> </div> <div class="flex items-center justify-between text-muted-foreground text-xs"> <Show when={props.task.assignee}> {(assignee) => ( <div class="flex items-center gap-1"> <Avatar class="size-4"> <AvatarImage src={props.task.assigneeAvatar} alt={assignee()} /> <AvatarFallback>{assignee().charAt(0)}</AvatarFallback> </Avatar> <span class="line-clamp-1">{assignee()}</span> </div> )} </Show> <Show when={props.task.dueDate}> {(dueDate) => ( <time class="whitespace-nowrap text-[10px] tabular-nums">{dueDate()}</time> )} </Show> </div> </CardContent> </Card> );}
function TaskCard(props: { task: Task; asHandle?: boolean }) { return ( <KanbanItem value={props.task.id}> <Show when={props.asHandle} fallback={<TaskCardContent task={props.task} />}> <KanbanItemHandle> <TaskCardContent task={props.task} /> </KanbanItemHandle> </Show> </KanbanItem> );}
function TaskColumn(props: { value: string; tasks: Task[] }) { return ( <KanbanColumn value={props.value}> <Card class="mb-2.5"> <CardHeader class="flex items-center justify-between px-3"> <div class="flex items-center gap-2.5"> <span class="font-semibold text-sm">{COLUMN_TITLES[props.value] ?? props.value}</span> <Badge variant="outline">{props.tasks.length}</Badge> </div> <KanbanColumnHandle as={Button} size="icon-xs" variant="ghost" aria-label={`Reorder ${COLUMN_TITLES[props.value] ?? props.value} column`} > <GripVertical /> </KanbanColumnHandle> </CardHeader> <CardContent class="px-3"> <KanbanColumnContent value={props.value} class="flex flex-col gap-2.5"> <For each={props.tasks}>{(task) => <TaskCard task={task} asHandle />}</For> </KanbanColumnContent> </CardContent> </Card> </KanbanColumn> );}
const defaultColumns: Record<string, Task[]> = { backlog: [ { id: "1", title: "Add authentication", priority: "high", assignee: "Alex Johnson", assigneeAvatar: "https://avatar.vercel.sh/alex-johnson", dueDate: "Jan 10, 2025", }, { id: "2", title: "Create API endpoints", priority: "medium", assignee: "Sarah Chen", assigneeAvatar: "https://avatar.vercel.sh/sarah-chen", dueDate: "Jan 15, 2025", }, { id: "3", title: "Write documentation", priority: "low", assignee: "Michael Rodriguez", assigneeAvatar: "https://avatar.vercel.sh/michael-rodriguez", dueDate: "Jan 20, 2025", }, ], inProgress: [ { id: "4", title: "Design system updates", priority: "high", assignee: "Emma Wilson", assigneeAvatar: "https://avatar.vercel.sh/emma-wilson", dueDate: "Aug 25, 2025", }, { id: "5", title: "Implement dark mode", priority: "medium", assignee: "David Kim", assigneeAvatar: "https://avatar.vercel.sh/david-kim", dueDate: "Aug 25, 2025", }, ], done: [ { id: "7", title: "Setup project", priority: "high", assignee: "Aron Thompson", assigneeAvatar: "https://avatar.vercel.sh/aron-thompson", dueDate: "Sep 25, 2025", }, { id: "8", title: "Initial commit", priority: "low", assignee: "James Brown", assigneeAvatar: "https://avatar.vercel.sh/james-brown", dueDate: "Sep 20, 2025", }, ],};
export default function KanbanDemo() { const [columns, setColumns] = createSignal<Record<string, Task[]>>(defaultColumns);
return ( <Kanban value={columns()} onValueChange={setColumns} getItemValue={(task) => task.id} class="w-full" > <KanbanBoard class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-3"> <For each={Object.keys(columns())}> {(columnValue) => <TaskColumn value={columnValue} tasks={columns()[columnValue] ?? []} />} </For> </KanbanBoard> <KanbanOverlay class="rounded-md border-2 border-dashed bg-muted/10" /> </Kanban> );}Kanban moves cards within and between columns, and reorders the columns themselves, with pointer, touch, and keyboard input. It is built on @dnd-kit/solid: the root wraps the board in a DragDropProvider, each KanbanColumn and KanbanItem registers itself with the sortable manager, and you keep full ownership of the card markup and the Record<string, T[]> board state.
Installation
Usage
import { For } from "solid-js";import { Kanban, KanbanBoard, KanbanColumn, KanbanColumnContent, KanbanColumnHandle, KanbanItem, KanbanItemHandle, KanbanOverlay,} from "~/components/blocks/kanban";<Kanban value={columns()} onValueChange={setColumns} getItemValue={(item) => item.id}> <KanbanBoard> <For each={Object.keys(columns())}> {(columnValue) => ( <KanbanColumn value={columnValue}> <KanbanColumnHandle> <h3>{columnValue}</h3> </KanbanColumnHandle> <KanbanColumnContent value={columnValue}> <For each={columns()[columnValue]}> {(item) => ( <KanbanItem value={item.id}> <KanbanItemHandle>{item.title}</KanbanItemHandle> </KanbanItem> )} </For> </KanbanColumnContent> </KanbanColumn> )} </For> </KanbanBoard> <KanbanOverlay class="size-full rounded-md bg-muted" /></Kanban>Iterate over Object.keys(columns()) rather than Object.entries(...): column keys are stable strings, so <For> reuses the existing column nodes when the board reshuffles instead of recreating them mid-drag.
Examples
Drag Overlay
KanbanOverlay renders a floating preview that follows the cursor. Pass a render function to receive { value, variant } — variant is "column" when a column header is being dragged and "item" when a card is. Rendering a KanbanColumn or KanbanItem inside the overlay reuses your own markup; overlay children skip drag registration and render presentational markup only.
import { GripVertical } from "lucide-solid";import { createSignal, For, Show } from "solid-js";import { Kanban, KanbanBoard, KanbanColumn, KanbanColumnContent, KanbanColumnHandle, KanbanItem, KanbanItemHandle, KanbanOverlay,} from "@/registry/kobalte/blocks/kanban";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Badge } from "~/components/ui/badge";import { Button } from "~/components/ui/button";import { Card, CardContent, CardHeader } from "~/components/ui/card";
type Task = { id: string; title: string; priority: "low" | "medium" | "high"; assignee?: string; assigneeAvatar?: string; dueDate?: string;};
const COLUMN_TITLES: Record<string, string> = { backlog: "Backlog", inProgress: "In Progress", review: "Review", done: "Done",};
function priorityVariant(priority: Task["priority"]) { if (priority === "high") return "destructive" as const; if (priority === "medium") return "default" as const; return "secondary" as const;}
function TaskCardContent(props: { task: Task }) { return ( <Card> <CardContent class="flex flex-col gap-2.5 px-3"> <div class="flex items-start justify-between gap-2"> <span class="min-w-0 flex-1 line-clamp-2 font-medium text-sm">{props.task.title}</span> <Badge variant={priorityVariant(props.task.priority)} class="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-[11px] capitalize" > {props.task.priority} </Badge> </div> <div class="flex items-center justify-between text-muted-foreground text-xs"> <Show when={props.task.assignee}> {(assignee) => ( <div class="flex items-center gap-1"> <Avatar class="size-4"> <AvatarImage src={props.task.assigneeAvatar} alt={assignee()} /> <AvatarFallback>{assignee().charAt(0)}</AvatarFallback> </Avatar> <span class="line-clamp-1">{assignee()}</span> </div> )} </Show> <Show when={props.task.dueDate}> {(dueDate) => ( <time class="whitespace-nowrap text-[10px] tabular-nums">{dueDate()}</time> )} </Show> </div> </CardContent> </Card> );}
function TaskCard(props: { task: Task; asHandle?: boolean }) { return ( <KanbanItem value={props.task.id}> <Show when={props.asHandle} fallback={<TaskCardContent task={props.task} />}> <KanbanItemHandle> <TaskCardContent task={props.task} /> </KanbanItemHandle> </Show> </KanbanItem> );}
function TaskColumn(props: { value: string; tasks: Task[]; isOverlay?: boolean }) { const title = () => COLUMN_TITLES[props.value] ?? props.value;
return ( <KanbanColumn value={props.value}> <Card class="mb-2.5"> <CardHeader class="flex items-center justify-between px-3"> <div class="flex items-center gap-2.5"> <span class="font-semibold text-sm">{title()}</span> <Badge variant="outline">{props.tasks.length}</Badge> </div> <KanbanColumnHandle as={Button} size="icon-xs" variant="ghost" aria-label={`Reorder ${title()} column`} > <GripVertical /> </KanbanColumnHandle> </CardHeader> <CardContent class="px-3"> <KanbanColumnContent value={props.value} class="flex flex-col gap-2.5 p-0.5"> <For each={props.tasks}> {(task) => <TaskCard task={task} asHandle={!props.isOverlay} />} </For> </KanbanColumnContent> </CardContent> </Card> </KanbanColumn> );}
const defaultColumns: Record<string, Task[]> = { backlog: [ { id: "1", title: "Add authentication", priority: "high", assignee: "Alex Johnson", assigneeAvatar: "https://avatar.vercel.sh/alex-johnson", dueDate: "Jan 10, 2025", }, { id: "2", title: "Create API endpoints", priority: "medium", assignee: "Sarah Chen", assigneeAvatar: "https://avatar.vercel.sh/sarah-chen", dueDate: "Jan 15, 2025", }, { id: "3", title: "Write documentation", priority: "low", assignee: "Michael Rodriguez", assigneeAvatar: "https://avatar.vercel.sh/michael-rodriguez", dueDate: "Jan 20, 2025", }, ], inProgress: [ { id: "4", title: "Design system updates", priority: "high", assignee: "Emma Wilson", assigneeAvatar: "https://avatar.vercel.sh/emma-wilson", dueDate: "Aug 25, 2025", }, { id: "5", title: "Implement dark mode", priority: "medium", assignee: "David Kim", assigneeAvatar: "https://avatar.vercel.sh/david-kim", dueDate: "Aug 25, 2025", }, ], done: [ { id: "7", title: "Setup project", priority: "high", assignee: "Aron Thompson", assigneeAvatar: "https://avatar.vercel.sh/aron-thompson", dueDate: "Sep 25, 2025", }, { id: "8", title: "Initial commit", priority: "low", assignee: "James Brown", assigneeAvatar: "https://avatar.vercel.sh/james-brown", dueDate: "Sep 20, 2025", }, ],};
export default function KanbanOverlayDemo() { const [columns, setColumns] = createSignal<Record<string, Task[]>>(defaultColumns);
const findTask = (id: string) => Object.values(columns()) .flat() .find((task) => task.id === id);
return ( <Kanban value={columns()} onValueChange={setColumns} getItemValue={(task) => task.id} class="w-full" > <KanbanBoard class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-3"> <For each={Object.keys(columns())}> {(columnValue) => <TaskColumn value={columnValue} tasks={columns()[columnValue] ?? []} />} </For> </KanbanBoard> <KanbanOverlay> {(params: { value: string; variant: "column" | "item" }) => ( <Show when={params.variant === "column"} fallback={ <Show when={findTask(params.value)}>{(task) => <TaskCard task={task()} />}</Show> } > <TaskColumn value={params.value} tasks={columns()[params.value] ?? []} isOverlay /> </Show> )} </KanbanOverlay> </Kanban> );}Persist to a Backend
import { GripVertical } from "lucide-solid";import { createSignal, For, Show } from "solid-js";import { Kanban, KanbanBoard, KanbanColumn, KanbanColumnContent, KanbanColumnHandle, type KanbanCommitMeta, KanbanItem, KanbanItemHandle, KanbanOverlay,} from "@/registry/kobalte/blocks/kanban";import { Badge } from "~/components/ui/badge";import { Button } from "~/components/ui/button";import { Card, CardContent, CardHeader } from "~/components/ui/card";
type Task = { id: string; title: string; priority: "low" | "medium" | "high";};
const COLUMN_TITLES: Record<string, string> = { todo: "To Do", inProgress: "In Progress", done: "Done",};
function priorityVariant(priority: Task["priority"]) { if (priority === "high") return "destructive" as const; if (priority === "medium") return "default" as const; return "secondary" as const;}
// Simulated backend. In a real app this would be a mutation or a fetch to your// API. Every other call fails so the optimistic rollback path is easy to see.let attempt = 0;function persistBoard(_meta: KanbanCommitMeta<Task>) { attempt += 1; const shouldFail = attempt % 2 === 0; return new Promise<void>((resolve, reject) => { setTimeout(() => (shouldFail ? reject(new Error("Network error")) : resolve()), 700); });}
function TaskCardContent(props: { task: Task }) { return ( <Card> <CardContent class="flex items-start justify-between gap-2 px-3"> <span class="min-w-0 flex-1 line-clamp-2 font-medium text-sm">{props.task.title}</span> <Badge variant={priorityVariant(props.task.priority)} class="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-xs capitalize" > {props.task.priority} </Badge> </CardContent> </Card> );}
function TaskCard(props: { task: Task }) { return ( <KanbanItem value={props.task.id}> <KanbanItemHandle> <TaskCardContent task={props.task} /> </KanbanItemHandle> </KanbanItem> );}
function TaskColumn(props: { value: string; tasks: Task[] }) { const title = () => COLUMN_TITLES[props.value] ?? props.value;
return ( <KanbanColumn value={props.value}> <Card class="mb-2.5"> <CardHeader class="flex items-center justify-between px-3"> <div class="flex items-center gap-2.5"> <span class="font-semibold text-sm">{title()}</span> <Badge variant="outline">{props.tasks.length}</Badge> </div> <KanbanColumnHandle as={Button} size="icon-xs" variant="ghost" aria-label={`Reorder ${title()} column`} > <GripVertical /> </KanbanColumnHandle> </CardHeader> <CardContent class="px-3"> <KanbanColumnContent value={props.value} class="flex flex-col gap-2.5"> <For each={props.tasks}>{(task) => <TaskCard task={task} />}</For> </KanbanColumnContent> </CardContent> </Card> </KanbanColumn> );}
const defaultColumns: Record<string, Task[]> = { todo: [ { id: "1", title: "Add authentication", priority: "high" }, { id: "2", title: "Create API endpoints", priority: "medium" }, { id: "3", title: "Write documentation", priority: "low" }, ], inProgress: [ { id: "4", title: "Design system updates", priority: "high" }, { id: "5", title: "Implement dark mode", priority: "medium" }, ], done: [{ id: "6", title: "Setup project", priority: "low" }],};
export default function KanbanPersistence() { const [columns, setColumns] = createSignal<Record<string, Task[]>>(defaultColumns); const [status, setStatus] = createSignal<"idle" | "saving" | "saved" | "failed">("idle"); const [label, setLabel] = createSignal("");
// Fires once per completed drag, never during the hover preview. The first // argument is the final board (already on screen, because onValueChange // applied it live), so only meta.previousValue is needed to roll back. const handleValueCommit = (_next: Record<string, Task[]>, meta: KanbanCommitMeta<Task>) => { const previous = meta.previousValue; setLabel( meta.kind === "column" ? `Reordered "${COLUMN_TITLES[meta.activeContainer] ?? meta.activeContainer}"` : `Moved "${meta.activeValue}" to "${COLUMN_TITLES[meta.overContainer] ?? meta.overContainer}"`, ); setStatus("saving");
persistBoard(meta) .then(() => setStatus("saved")) .catch(() => { // Roll back to the pre-drag arrangement. In production prefer a refetch // here so a newer drag is not clobbered by this snapshot. setColumns(previous); setStatus("failed"); }); };
return ( <div class="w-full space-y-3"> <Kanban value={columns()} onValueChange={setColumns} getItemValue={(task) => task.id} onValueCommit={handleValueCommit} > <KanbanBoard class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-3"> <For each={Object.keys(columns())}> {(columnValue) => ( <TaskColumn value={columnValue} tasks={columns()[columnValue] ?? []} /> )} </For> </KanbanBoard> <KanbanOverlay class="rounded-md border-2 border-dashed bg-muted/10" /> </Kanban> <div class="flex h-6 items-center" aria-live="polite"> <Show when={status() === "saving"}> <Badge variant="secondary">Saving board…</Badge> </Show> <Show when={status() === "saved"}> <Badge>{label()} — saved</Badge> </Show> <Show when={status() === "failed"}> <Badge variant="destructive">Could not save — board restored</Badge> </Show> </div> </div> );}Persisting Moves
Keep value and onValueChange so the board reshuffles live while dragging, and use onValueCommit to save. It fires once per completed drag (never during the hover preview) with the final board and a previousValue snapshot, so you can update optimistically and roll back on error. Its meta.kind tells you whether a card moved ("item") or a column was reordered ("column"), and meta.activeValue is the id of whatever was dragged.
<Kanban value={columns()} onValueChange={setColumns} getItemValue={(task) => task.id} onValueCommit={(next, meta) => { if (meta.kind === "column") { reorderColumns({ order: Object.keys(next) }); return; }
moveCard({ id: meta.activeValue, to: meta.overContainer, index: meta.overIndex, }).catch(() => { setColumns(meta.previousValue); // roll back }); }}> {/* ... */}</Kanban>If a user might start another drag before the mutation settles, prefer a refetch on failure instead of restoring the snapshot, so a newer arrangement is not clobbered.
If you do not need the live cross-column preview, onMove is a simpler alternative: it fires once on drop for item moves and lets you apply the move yourself. Column reorders still arrive through onValueChange.
Accessibility
- Columns and cards are keyboard sortable via the
KeyboardSensor: focus a handle, pick it up with Space or Enter, move it with the arrow keys, drop it with Space or Enter, and cancel with Esc. - The
PointerSensorcovers both mouse and touch input. @dnd-kit/solidinstalls its accessibility plugin automatically, so handles receiverole="button",tabindex,aria-roledescription="draggable", and a live-region description. There is noaccessibilityprop to configure — unlike React dnd-kit v6, announcements are owned by the library.- When a
KanbanColumnHandleorKanbanItemHandleis present, only the handle initiates a drag, so interactive controls inside a card stay usable.
Server-Side Rendering
@dnd-kit/solid is browser-only, so Kanban renders purely presentational markup during SSR and hydration, then engages drag and drop one tick after mount. The server HTML is visually identical, but handles are not focusable and carry no drag-related ARIA attributes until hydration completes — keep this in mind when asserting on SSR output or querying for tabindex/aria-* attributes in tests that don't run a browser.
API Reference
Kanban
The root component that provides the kanban context and manages the board state.
The root exposes data-slot="kanban" and sets data-dragging while a drag is active.
KanbanCommitMeta<T> is { kind: "item" | "column"; event: KanbanDragEndEvent; activeValue: string; activeContainer: string; activeIndex: number; overContainer: string; overIndex: number; previousValue: Record<string, T[]> }. KanbanMoveEvent is the same shape without kind and previousValue.
KanbanBoard
The container for the kanban columns. Purely presentational — ordering comes from each column's index in value.
Exposes data-slot="kanban-board".
KanbanColumn
An individual column within the kanban board. Registers itself as a droppable that accepts both columns (for reordering) and cards (for dropping onto empty space).
Exposes data-slot="kanban-column", data-value, and sets data-dragging / data-disabled when applicable.
KanbanColumnHandle
The drag handle for a column. When present, only the handle starts a column drag instead of the whole column.
as replaces upstream's render prop: <KanbanColumnHandle as={Button} variant="ghost" size="icon-xs"> renders a Button and forwards its props.
Exposes data-slot="kanban-column-handle" and mirrors data-dragging / data-disabled from its column. The handle fades in on column hover by default.
KanbanColumnContent
The area within a column that holds its cards.
Throws when value is not a key of the root's value, which catches typos early. Exposes data-slot="kanban-column-content".
KanbanItem
An individual draggable card within a column. When rendered inside <KanbanOverlay />, registration is skipped and only presentational markup is rendered.
Exposes data-slot="kanban-item", data-value, and sets data-dragging / data-disabled when applicable.
KanbanItemHandle
The drag handle for a card. When present, only the handle starts a card drag instead of the whole card.
Exposes data-slot="kanban-item-handle" and mirrors data-dragging / data-disabled from its item.
KanbanOverlay
The ghost element displayed during a drag. Pass either static JSX (rendered whenever a drag is active) or a render function ({ value, variant }) => JSX. DragOverlay self-portals — no <Portal> wrapper is needed.