ZaidanHomeDocsComponentsBlocksChartsTypesetCreate

Command Palette

Search for a command to run...

GitHub
New
Sections
  • Introduction
  • Components
  • Blocks
  • Installation
  • Customization
  • Dark Mode
  • Typeset
  • Zaidan Skills
  • FAQ
  • Roadmap
  • Changelog
Installation
  • Astro
  • Manual
  • Solid Start
  • TanStack Router
  • TanStack Start
  • Vite
Blocks
  • Data Grid
  • Event Calendar
  • Filters
  • Gantt
  • Image Crop
  • Kanban
  • Message Scroller
  • Questionnaire
  • Sortable
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Marker
  • Menubar
  • Message
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip

Kanban

Backlog3
Add authenticationhigh
AAlex Johnson
Jan 10, 2025
Create API endpointsmedium
SSarah Chen
Jan 15, 2025
Write documentationlow
MMichael Rodriguez
Jan 20, 2025
In Progress2
Design system updateshigh
EEmma Wilson
Aug 25, 2025
Implement dark modemedium
DDavid Kim
Aug 25, 2025
Done2
Setup projecthigh
AAron Thompson
Sep 25, 2025
Initial commitlow
JJames Brown
Sep 20, 2025
1
import { GripVertical } from "lucide-solid";
2
import { createSignal, For, Show } from "solid-js";
3
import {
4
Kanban,
5
KanbanBoard,
6
KanbanColumn,
7
KanbanColumnContent,
8
KanbanColumnHandle,
9
KanbanItem,
10
KanbanItemHandle,
11
KanbanOverlay,
12
} from "@/registry/kobalte/blocks/kanban";
13
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
14
import { Badge } from "~/components/ui/badge";
15
import { Button } from "~/components/ui/button";
16
import { Card, CardContent, CardHeader } from "~/components/ui/card";
17
18
type Task = {
19
id: string;
20
title: string;
21
priority: "low" | "medium" | "high";
22
assignee?: string;
23
assigneeAvatar?: string;
24
dueDate?: string;
25
};
26
27
const COLUMN_TITLES: Record<string, string> = {
28
backlog: "Backlog",
29
inProgress: "In Progress",
30
review: "Review",
31
done: "Done",
32
};
33
34
function priorityVariant(priority: Task["priority"]) {
35
if (priority === "high") return "destructive" as const;
36
if (priority === "medium") return "default" as const;
37
return "secondary" as const;
38
}
39
40
function TaskCardContent(props: { task: Task }) {
41
return (
42
<Card>
43
<CardContent class="space-y-2.5 px-3">
44
<div class="flex items-start justify-between gap-2">
45
<span class="min-w-0 flex-1 line-clamp-2 font-medium text-sm">{props.task.title}</span>
46
<Badge
47
variant={priorityVariant(props.task.priority)}
48
class="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-xs capitalize"
49
>
50
{props.task.priority}
51
</Badge>
52
</div>
53
<div class="flex items-center justify-between text-muted-foreground text-xs">
54
<Show when={props.task.assignee}>
55
{(assignee) => (
56
<div class="flex items-center gap-1">
57
<Avatar class="size-4">
58
<AvatarImage src={props.task.assigneeAvatar} alt={assignee()} />
59
<AvatarFallback>{assignee().charAt(0)}</AvatarFallback>
60
</Avatar>
61
<span class="line-clamp-1">{assignee()}</span>
62
</div>
63
)}
64
</Show>
65
<Show when={props.task.dueDate}>
66
{(dueDate) => (
67
<time class="whitespace-nowrap text-[10px] tabular-nums">{dueDate()}</time>
68
)}
69
</Show>
70
</div>
71
</CardContent>
72
</Card>
73
);
74
}
75
76
function TaskCard(props: { task: Task; asHandle?: boolean }) {
77
return (
78
<KanbanItem value={props.task.id}>
79
<Show when={props.asHandle} fallback={<TaskCardContent task={props.task} />}>
80
<KanbanItemHandle>
81
<TaskCardContent task={props.task} />
82
</KanbanItemHandle>
83
</Show>
84
</KanbanItem>
85
);
86
}
87
88
function TaskColumn(props: { value: string; tasks: Task[] }) {
89
return (
90
<KanbanColumn value={props.value}>
91
<Card class="mb-2.5">
92
<CardHeader class="flex items-center justify-between px-3">
93
<div class="flex items-center gap-2.5">
94
<span class="font-semibold text-sm">{COLUMN_TITLES[props.value] ?? props.value}</span>
95
<Badge variant="outline">{props.tasks.length}</Badge>
96
</div>
97
<KanbanColumnHandle
98
as={Button}
99
size="icon-xs"
100
variant="ghost"
101
aria-label={`Reorder ${COLUMN_TITLES[props.value] ?? props.value} column`}
102
>
103
<GripVertical />
104
</KanbanColumnHandle>
105
</CardHeader>
106
<CardContent class="px-3">
107
<KanbanColumnContent value={props.value} class="flex flex-col gap-2.5">
108
<For each={props.tasks}>{(task) => <TaskCard task={task} asHandle />}</For>
109
</KanbanColumnContent>
110
</CardContent>
111
</Card>
112
</KanbanColumn>
113
);
114
}
115
116
const defaultColumns: Record<string, Task[]> = {
117
backlog: [
118
{
119
id: "1",
120
title: "Add authentication",
121
priority: "high",
122
assignee: "Alex Johnson",
123
assigneeAvatar: "https://avatar.vercel.sh/alex-johnson",
124
dueDate: "Jan 10, 2025",
125
},
126
{
127
id: "2",
128
title: "Create API endpoints",
129
priority: "medium",
130
assignee: "Sarah Chen",
131
assigneeAvatar: "https://avatar.vercel.sh/sarah-chen",
132
dueDate: "Jan 15, 2025",
133
},
134
{
135
id: "3",
136
title: "Write documentation",
137
priority: "low",
138
assignee: "Michael Rodriguez",
139
assigneeAvatar: "https://avatar.vercel.sh/michael-rodriguez",
140
dueDate: "Jan 20, 2025",
141
},
142
],
143
inProgress: [
144
{
145
id: "4",
146
title: "Design system updates",
147
priority: "high",
148
assignee: "Emma Wilson",
149
assigneeAvatar: "https://avatar.vercel.sh/emma-wilson",
150
dueDate: "Aug 25, 2025",
151
},
152
{
153
id: "5",
154
title: "Implement dark mode",
155
priority: "medium",
156
assignee: "David Kim",
157
assigneeAvatar: "https://avatar.vercel.sh/david-kim",
158
dueDate: "Aug 25, 2025",
159
},
160
],
161
done: [
162
{
163
id: "7",
164
title: "Setup project",
165
priority: "high",
166
assignee: "Aron Thompson",
167
assigneeAvatar: "https://avatar.vercel.sh/aron-thompson",
168
dueDate: "Sep 25, 2025",
169
},
170
{
171
id: "8",
172
title: "Initial commit",
173
priority: "low",
174
assignee: "James Brown",
175
assigneeAvatar: "https://avatar.vercel.sh/james-brown",
176
dueDate: "Sep 20, 2025",
177
},
178
],
179
};
180
181
export default function KanbanDemo() {
182
const [columns, setColumns] = createSignal<Record<string, Task[]>>(defaultColumns);
183
184
return (
185
<Kanban
186
value={columns()}
187
onValueChange={setColumns}
188
getItemValue={(task) => task.id}
189
class="w-full"
190
>
191
<KanbanBoard class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-3">
192
<For each={Object.keys(columns())}>
193
{(columnValue) => <TaskColumn value={columnValue} tasks={columns()[columnValue] ?? []} />}
194
</For>
195
</KanbanBoard>
196
<KanbanOverlay class="rounded-md border-2 border-dashed bg-muted/10" />
197
</Kanban>
198
);
199
}

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

pnpm dlx shadcn@latest add @zaidan/kanban
npx shadcn@latest add @zaidan/kanban
yarn dlx shadcn@latest add @zaidan/kanban
bunx --bun shadcn@latest add @zaidan/kanban

Usage

1
import { For } from "solid-js";
2
import {
3
Kanban,
4
KanbanBoard,
5
KanbanColumn,
6
KanbanColumnContent,
7
KanbanColumnHandle,
8
KanbanItem,
9
KanbanItemHandle,
10
KanbanOverlay,
11
} from "~/components/blocks/kanban";
1
<Kanban value={columns()} onValueChange={setColumns} getItemValue={(item) => item.id}>
2
<KanbanBoard>
3
<For each={Object.keys(columns())}>
4
{(columnValue) => (
5
<KanbanColumn value={columnValue}>
6
<KanbanColumnHandle>
7
<h3>{columnValue}</h3>
8
</KanbanColumnHandle>
9
<KanbanColumnContent value={columnValue}>
10
<For each={columns()[columnValue]}>
11
{(item) => (
12
<KanbanItem value={item.id}>
13
<KanbanItemHandle>{item.title}</KanbanItemHandle>
14
</KanbanItem>
15
)}
16
</For>
17
</KanbanColumnContent>
18
</KanbanColumn>
19
)}
20
</For>
21
</KanbanBoard>
22
<KanbanOverlay class="size-full rounded-md bg-muted" />
23
</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.

Backlog3
Add authenticationhigh
AAlex Johnson
Jan 10, 2025
Create API endpointsmedium
SSarah Chen
Jan 15, 2025
Write documentationlow
MMichael Rodriguez
Jan 20, 2025
In Progress2
Design system updateshigh
EEmma Wilson
Aug 25, 2025
Implement dark modemedium
DDavid Kim
Aug 25, 2025
Done2
Setup projecthigh
AAron Thompson
Sep 25, 2025
Initial commitlow
JJames Brown
Sep 20, 2025
1
import { GripVertical } from "lucide-solid";
2
import { createSignal, For, Show } from "solid-js";
3
import {
4
Kanban,
5
KanbanBoard,
6
KanbanColumn,
7
KanbanColumnContent,
8
KanbanColumnHandle,
9
KanbanItem,
10
KanbanItemHandle,
11
KanbanOverlay,
12
} from "@/registry/kobalte/blocks/kanban";
13
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
14
import { Badge } from "~/components/ui/badge";
15
import { Button } from "~/components/ui/button";
16
import { Card, CardContent, CardHeader } from "~/components/ui/card";
17
18
type Task = {
19
id: string;
20
title: string;
21
priority: "low" | "medium" | "high";
22
assignee?: string;
23
assigneeAvatar?: string;
24
dueDate?: string;
25
};
26
27
const COLUMN_TITLES: Record<string, string> = {
28
backlog: "Backlog",
29
inProgress: "In Progress",
30
review: "Review",
31
done: "Done",
32
};
33
34
function priorityVariant(priority: Task["priority"]) {
35
if (priority === "high") return "destructive" as const;
36
if (priority === "medium") return "default" as const;
37
return "secondary" as const;
38
}
39
40
function TaskCardContent(props: { task: Task }) {
41
return (
42
<Card>
43
<CardContent class="flex flex-col gap-2.5 px-3">
44
<div class="flex items-start justify-between gap-2">
45
<span class="min-w-0 flex-1 line-clamp-2 font-medium text-sm">{props.task.title}</span>
46
<Badge
47
variant={priorityVariant(props.task.priority)}
48
class="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-[11px] capitalize"
49
>
50
{props.task.priority}
51
</Badge>
52
</div>
53
<div class="flex items-center justify-between text-muted-foreground text-xs">
54
<Show when={props.task.assignee}>
55
{(assignee) => (
56
<div class="flex items-center gap-1">
57
<Avatar class="size-4">
58
<AvatarImage src={props.task.assigneeAvatar} alt={assignee()} />
59
<AvatarFallback>{assignee().charAt(0)}</AvatarFallback>
60
</Avatar>
61
<span class="line-clamp-1">{assignee()}</span>
62
</div>
63
)}
64
</Show>
65
<Show when={props.task.dueDate}>
66
{(dueDate) => (
67
<time class="whitespace-nowrap text-[10px] tabular-nums">{dueDate()}</time>
68
)}
69
</Show>
70
</div>
71
</CardContent>
72
</Card>
73
);
74
}
75
76
function TaskCard(props: { task: Task; asHandle?: boolean }) {
77
return (
78
<KanbanItem value={props.task.id}>
79
<Show when={props.asHandle} fallback={<TaskCardContent task={props.task} />}>
80
<KanbanItemHandle>
81
<TaskCardContent task={props.task} />
82
</KanbanItemHandle>
83
</Show>
84
</KanbanItem>
85
);
86
}
87
88
function TaskColumn(props: { value: string; tasks: Task[]; isOverlay?: boolean }) {
89
const title = () => COLUMN_TITLES[props.value] ?? props.value;
90
91
return (
92
<KanbanColumn value={props.value}>
93
<Card class="mb-2.5">
94
<CardHeader class="flex items-center justify-between px-3">
95
<div class="flex items-center gap-2.5">
96
<span class="font-semibold text-sm">{title()}</span>
97
<Badge variant="outline">{props.tasks.length}</Badge>
98
</div>
99
<KanbanColumnHandle
100
as={Button}
101
size="icon-xs"
102
variant="ghost"
103
aria-label={`Reorder ${title()} column`}
104
>
105
<GripVertical />
106
</KanbanColumnHandle>
107
</CardHeader>
108
<CardContent class="px-3">
109
<KanbanColumnContent value={props.value} class="flex flex-col gap-2.5 p-0.5">
110
<For each={props.tasks}>
111
{(task) => <TaskCard task={task} asHandle={!props.isOverlay} />}
112
</For>
113
</KanbanColumnContent>
114
</CardContent>
115
</Card>
116
</KanbanColumn>
117
);
118
}
119
120
const defaultColumns: Record<string, Task[]> = {
121
backlog: [
122
{
123
id: "1",
124
title: "Add authentication",
125
priority: "high",
126
assignee: "Alex Johnson",
127
assigneeAvatar: "https://avatar.vercel.sh/alex-johnson",
128
dueDate: "Jan 10, 2025",
129
},
130
{
131
id: "2",
132
title: "Create API endpoints",
133
priority: "medium",
134
assignee: "Sarah Chen",
135
assigneeAvatar: "https://avatar.vercel.sh/sarah-chen",
136
dueDate: "Jan 15, 2025",
137
},
138
{
139
id: "3",
140
title: "Write documentation",
141
priority: "low",
142
assignee: "Michael Rodriguez",
143
assigneeAvatar: "https://avatar.vercel.sh/michael-rodriguez",
144
dueDate: "Jan 20, 2025",
145
},
146
],
147
inProgress: [
148
{
149
id: "4",
150
title: "Design system updates",
151
priority: "high",
152
assignee: "Emma Wilson",
153
assigneeAvatar: "https://avatar.vercel.sh/emma-wilson",
154
dueDate: "Aug 25, 2025",
155
},
156
{
157
id: "5",
158
title: "Implement dark mode",
159
priority: "medium",
160
assignee: "David Kim",
161
assigneeAvatar: "https://avatar.vercel.sh/david-kim",
162
dueDate: "Aug 25, 2025",
163
},
164
],
165
done: [
166
{
167
id: "7",
168
title: "Setup project",
169
priority: "high",
170
assignee: "Aron Thompson",
171
assigneeAvatar: "https://avatar.vercel.sh/aron-thompson",
172
dueDate: "Sep 25, 2025",
173
},
174
{
175
id: "8",
176
title: "Initial commit",
177
priority: "low",
178
assignee: "James Brown",
179
assigneeAvatar: "https://avatar.vercel.sh/james-brown",
180
dueDate: "Sep 20, 2025",
181
},
182
],
183
};
184
185
export default function KanbanOverlayDemo() {
186
const [columns, setColumns] = createSignal<Record<string, Task[]>>(defaultColumns);
187
188
const findTask = (id: string) =>
189
Object.values(columns())
190
.flat()
191
.find((task) => task.id === id);
192
193
return (
194
<Kanban
195
value={columns()}
196
onValueChange={setColumns}
197
getItemValue={(task) => task.id}
198
class="w-full"
199
>
200
<KanbanBoard class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-3">
201
<For each={Object.keys(columns())}>
202
{(columnValue) => <TaskColumn value={columnValue} tasks={columns()[columnValue] ?? []} />}
203
</For>
204
</KanbanBoard>
205
<KanbanOverlay>
206
{(params: { value: string; variant: "column" | "item" }) => (
207
<Show
208
when={params.variant === "column"}
209
fallback={
210
<Show when={findTask(params.value)}>{(task) => <TaskCard task={task()} />}</Show>
211
}
212
>
213
<TaskColumn value={params.value} tasks={columns()[params.value] ?? []} isOverlay />
214
</Show>
215
)}
216
</KanbanOverlay>
217
</Kanban>
218
);
219
}

Persist to a Backend

To Do3
Add authenticationhigh
Create API endpointsmedium
Write documentationlow
In Progress2
Design system updateshigh
Implement dark modemedium
Done1
Setup projectlow
1
import { GripVertical } from "lucide-solid";
2
import { createSignal, For, Show } from "solid-js";
3
import {
4
Kanban,
5
KanbanBoard,
6
KanbanColumn,
7
KanbanColumnContent,
8
KanbanColumnHandle,
9
type KanbanCommitMeta,
10
KanbanItem,
11
KanbanItemHandle,
12
KanbanOverlay,
13
} from "@/registry/kobalte/blocks/kanban";
14
import { Badge } from "~/components/ui/badge";
15
import { Button } from "~/components/ui/button";
16
import { Card, CardContent, CardHeader } from "~/components/ui/card";
17
18
type Task = {
19
id: string;
20
title: string;
21
priority: "low" | "medium" | "high";
22
};
23
24
const COLUMN_TITLES: Record<string, string> = {
25
todo: "To Do",
26
inProgress: "In Progress",
27
done: "Done",
28
};
29
30
function priorityVariant(priority: Task["priority"]) {
31
if (priority === "high") return "destructive" as const;
32
if (priority === "medium") return "default" as const;
33
return "secondary" as const;
34
}
35
36
// Simulated backend. In a real app this would be a mutation or a fetch to your
37
// API. Every other call fails so the optimistic rollback path is easy to see.
38
let attempt = 0;
39
function persistBoard(_meta: KanbanCommitMeta<Task>) {
40
attempt += 1;
41
const shouldFail = attempt % 2 === 0;
42
return new Promise<void>((resolve, reject) => {
43
setTimeout(() => (shouldFail ? reject(new Error("Network error")) : resolve()), 700);
44
});
45
}
46
47
function TaskCardContent(props: { task: Task }) {
48
return (
49
<Card>
50
<CardContent class="flex items-start justify-between gap-2 px-3">
51
<span class="min-w-0 flex-1 line-clamp-2 font-medium text-sm">{props.task.title}</span>
52
<Badge
53
variant={priorityVariant(props.task.priority)}
54
class="pointer-events-none h-5 shrink-0 rounded-sm px-1.5 text-xs capitalize"
55
>
56
{props.task.priority}
57
</Badge>
58
</CardContent>
59
</Card>
60
);
61
}
62
63
function TaskCard(props: { task: Task }) {
64
return (
65
<KanbanItem value={props.task.id}>
66
<KanbanItemHandle>
67
<TaskCardContent task={props.task} />
68
</KanbanItemHandle>
69
</KanbanItem>
70
);
71
}
72
73
function TaskColumn(props: { value: string; tasks: Task[] }) {
74
const title = () => COLUMN_TITLES[props.value] ?? props.value;
75
76
return (
77
<KanbanColumn value={props.value}>
78
<Card class="mb-2.5">
79
<CardHeader class="flex items-center justify-between px-3">
80
<div class="flex items-center gap-2.5">
81
<span class="font-semibold text-sm">{title()}</span>
82
<Badge variant="outline">{props.tasks.length}</Badge>
83
</div>
84
<KanbanColumnHandle
85
as={Button}
86
size="icon-xs"
87
variant="ghost"
88
aria-label={`Reorder ${title()} column`}
89
>
90
<GripVertical />
91
</KanbanColumnHandle>
92
</CardHeader>
93
<CardContent class="px-3">
94
<KanbanColumnContent value={props.value} class="flex flex-col gap-2.5">
95
<For each={props.tasks}>{(task) => <TaskCard task={task} />}</For>
96
</KanbanColumnContent>
97
</CardContent>
98
</Card>
99
</KanbanColumn>
100
);
101
}
102
103
const defaultColumns: Record<string, Task[]> = {
104
todo: [
105
{ id: "1", title: "Add authentication", priority: "high" },
106
{ id: "2", title: "Create API endpoints", priority: "medium" },
107
{ id: "3", title: "Write documentation", priority: "low" },
108
],
109
inProgress: [
110
{ id: "4", title: "Design system updates", priority: "high" },
111
{ id: "5", title: "Implement dark mode", priority: "medium" },
112
],
113
done: [{ id: "6", title: "Setup project", priority: "low" }],
114
};
115
116
export default function KanbanPersistence() {
117
const [columns, setColumns] = createSignal<Record<string, Task[]>>(defaultColumns);
118
const [status, setStatus] = createSignal<"idle" | "saving" | "saved" | "failed">("idle");
119
const [label, setLabel] = createSignal("");
120
121
// Fires once per completed drag, never during the hover preview. The first
122
// argument is the final board (already on screen, because onValueChange
123
// applied it live), so only meta.previousValue is needed to roll back.
124
const handleValueCommit = (_next: Record<string, Task[]>, meta: KanbanCommitMeta<Task>) => {
125
const previous = meta.previousValue;
126
setLabel(
127
meta.kind === "column"
128
? `Reordered "${COLUMN_TITLES[meta.activeContainer] ?? meta.activeContainer}"`
129
: `Moved "${meta.activeValue}" to "${COLUMN_TITLES[meta.overContainer] ?? meta.overContainer}"`,
130
);
131
setStatus("saving");
132
133
persistBoard(meta)
134
.then(() => setStatus("saved"))
135
.catch(() => {
136
// Roll back to the pre-drag arrangement. In production prefer a refetch
137
// here so a newer drag is not clobbered by this snapshot.
138
setColumns(previous);
139
setStatus("failed");
140
});
141
};
142
143
return (
144
<div class="w-full space-y-3">
145
<Kanban
146
value={columns()}
147
onValueChange={setColumns}
148
getItemValue={(task) => task.id}
149
onValueCommit={handleValueCommit}
150
>
151
<KanbanBoard class="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-3">
152
<For each={Object.keys(columns())}>
153
{(columnValue) => (
154
<TaskColumn value={columnValue} tasks={columns()[columnValue] ?? []} />
155
)}
156
</For>
157
</KanbanBoard>
158
<KanbanOverlay class="rounded-md border-2 border-dashed bg-muted/10" />
159
</Kanban>
160
<div class="flex h-6 items-center" aria-live="polite">
161
<Show when={status() === "saving"}>
162
<Badge variant="secondary">Saving board…</Badge>
163
</Show>
164
<Show when={status() === "saved"}>
165
<Badge>{label()} — saved</Badge>
166
</Show>
167
<Show when={status() === "failed"}>
168
<Badge variant="destructive">Could not save — board restored</Badge>
169
</Show>
170
</div>
171
</div>
172
);
173
}

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.

1
<Kanban
2
value={columns()}
3
onValueChange={setColumns}
4
getItemValue={(task) => task.id}
5
onValueCommit={(next, meta) => {
6
if (meta.kind === "column") {
7
reorderColumns({ order: Object.keys(next) });
8
return;
9
}
10
11
moveCard({
12
id: meta.activeValue,
13
to: meta.overContainer,
14
index: meta.overIndex,
15
}).catch(() => {
16
setColumns(meta.previousValue); // roll back
17
});
18
}}
19
>
20
{/* ... */}
21
</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 PointerSensor covers both mouse and touch input.
  • @dnd-kit/solid installs its accessibility plugin automatically, so handles receive role="button", tabindex, aria-roledescription="draggable", and a live-region description. There is no accessibility prop to configure — unlike React dnd-kit v6, announcements are owned by the library.
  • When a KanbanColumnHandle or KanbanItemHandle is 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.

PropTypeDefaultDescription
valueRecord<string, T[]>-Required. The current state of columns and their items.
onValueChange(value: Record<string, T[]>) => void-Required. Fired when items move within or between columns. In the default mode it also fires during the drag to render the live preview, so avoid persisting from here directly.
getItemValue(item: T) => string-Required. Extracts a unique string id from an item. Ids must be unique across all columns.
onValueCommit(value: Record<string, T[]>, meta: KanbanCommitMeta<T>) => void-Fired once per completed drag with the final board and a previousValue snapshot. Use it to persist moves to a backend. See Persisting Moves.
onMove(event: KanbanMoveEvent) => void-Opt-in single commit point for item moves. When set, the live preview is disabled and you apply the move yourself.
restoreOnCancelbooleanfalseWhen true, cancelling a drag (for example pressing Esc) restores the board to its pre-drag arrangement.
onDragStart(event: KanbanDragStartEvent) => void-Raw @dnd-kit/solid passthrough, fired when a drag starts.
onDragEnd(event: KanbanDragEndEvent) => void-Raw passthrough, fired after internal cleanup and before the final onValueChange/onMove call. In the default mode, onValueChange has already fired during dragOver.
onDragCancel(event: KanbanDragEndEvent) => void-Fired when a drag is cancelled. @dnd-kit/solid has no separate cancel event, so this is dispatched from dragend when event.canceled is true.
modifiersunknown[]-Modifiers forwarded to DragDropProvider. Typed loosely because @dnd-kit/solid does not re-export the modifier type.
asValidComponent"div"The element or component to render as.
classstring-Additional CSS classes for the container.

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.

PropTypeDefaultDescription
asValidComponent"div"The element or component to render as.
classstring-Additional CSS classes for the board.

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).

PropTypeDefaultDescription
valuestring-Required. The unique identifier for the column.
disabledbooleanfalseWhether the column is disabled (not draggable).
asValidComponent"div"The element or component to render as.
classstring-Additional CSS classes for the column.

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.

PropTypeDefaultDescription
cursorbooleantrueWhether to apply cursor-grab / cursor-grabbing styles to the handle automatically.
asValidComponent"div"The element or component to render as. Also widens the accepted props.
classstring-Additional CSS classes for the handle.

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.

PropTypeDefaultDescription
valuestring-Required. The identifier of the column this content belongs to.
asValidComponent"div"The element or component to render as.
classstring-Additional CSS classes for the content area.

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.

PropTypeDefaultDescription
valuestring-Required. The unique identifier for the item.
disabledbooleanfalseWhether the item is disabled (not draggable).
asValidComponent"div"The element or component to render as.
classstring-Additional CSS classes for the item.

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.

PropTypeDefaultDescription
cursorbooleantrueWhether to apply cursor-grab / cursor-grabbing styles to the handle automatically.
asValidComponent"div"The element or component to render as. Also widens the accepted props.
classstring-Additional CSS classes for the handle.

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.

PropTypeDefaultDescription
childrenJSX.Element | ((params: { value: string; variant: "column" | "item" }) => JSX.Element)-Static JSX or a render function that receives the active drag.
dropAnimation{ duration: number; easing: string } | null{ duration: 250, easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)" }Drop animation config. Pass null to disable.
classstring-Additional CSS classes for the overlay container.

On This Page

  • Installation
  • Usage
  • Examples
    • Drag Overlay
    • Persist to a Backend
  • Persisting Moves
  • Accessibility
  • Server-Side Rendering
  • API Reference
    • Kanban
    • KanbanBoard
    • KanbanColumn
    • KanbanColumnHandle
    • KanbanColumnContent
    • KanbanItem
    • KanbanItemHandle
    • KanbanOverlay
Built by Kevin Abatan. The source code is available on GitHub.