Kanban
Shared ComponentsA controlled task board with drag handles, native move controls, empty columns, and custom card content.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { useState } from "react";
import { Kanban, type KanbanColumn } from "@/components/ui/kanban";
import { Button } from "@dashboardpack/core/components/ui/button";
const initial: KanbanColumn[] = [
{
id: "planned",
title: "Planned",
cards: [
{
id: "brand",
title: "Define the visual language",
description: "A considered palette, clear type, and room to breathe.",
},
{
id: "research",
title: "Talk to the people who use it",
description: "Five interviews before the next design review.",
},
],
},
{
id: "progress",
title: "In progress",
cards: [
{
id: "explorer",
title: "Build the component explorer",
description: "Real previews and code that is ready to adapt.",
},
],
},
{
id: "done",
title: "Ready to ship",
cards: [
{
id: "a11y",
title: "Make the details accessible",
description: "Keyboard, contrast, and a comfortable reading rhythm.",
},
],
},
];
export default function KanbanExample() {
const [columns, setColumns] = useState(initial);
const [readOnly, setReadOnly] = useState(false);
return (
<div className="w-full max-w-6xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-widest text-primary">
Studio / Launch week
</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight">
Good work, moving forward.
</h3>
<p className="mt-2 text-sm text-muted-foreground">
A controlled board with drag handles and native move controls for
every card.
</p>
</div>
<Button
variant="outline"
size="sm"
aria-pressed={readOnly}
onClick={() => setReadOnly(!readOnly)}
>
{readOnly ? "Enable editing" : "Preview read-only"}
</Button>
</div>
<Kanban
label="Launch board"
columns={columns}
onColumnsChange={setColumns}
disabled={readOnly}
/>
<p className="text-xs text-muted-foreground">
Changes stay in this preview. The parent owns the columns; connect
onColumnsChange to your store or API.
</p>
</div>
);
}Usage
Provide uniquely identified columns with cards and own their state through onColumnsChange(nextColumns, move). The component performs immutable local movement and reports the card ID, source/destination IDs, and requested index; persistence, permissions, pending state, and failed-write rollback belong to the parent. The exported moveKanbanCard helper rejects missing cards, disabled columns, and invalid indices, clamps bounds, and preserves the original input. renderCard can show application-specific metadata and actions. Without a callback or with disabled, the board is read-only. The Kanban app uses the same component with its task store and creation/deletion dialog.
import { Kanban, type KanbanColumn } from "@/components/ui/kanban";Shipped as editable source in src/components/ui/kanban.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/kanban.tsx
"use client";
import { useId, useState, type ReactNode } from "react";
import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd";
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface KanbanCard {
id: string;
title: string;
description?: string;
}
export interface KanbanColumn<T extends KanbanCard = KanbanCard> {
id: string;
title: string;
cards: T[];
disabled?: boolean;
}
export interface KanbanMove {
cardId: string;
fromColumnId: string;
toColumnId: string;
toIndex: number;
}
export function moveKanbanCard<T extends KanbanCard>(
columns: KanbanColumn<T>[],
move: KanbanMove,
): KanbanColumn<T>[] {
const source = columns.find((column) => column.id === move.fromColumnId);
const destination = columns.find((column) => column.id === move.toColumnId);
const index =
source?.cards.findIndex((card) => card.id === move.cardId) ?? -1;
if (
!source ||
!destination ||
source.disabled ||
destination.disabled ||
index < 0 ||
!Number.isInteger(move.toIndex)
)
return columns;
const target = Math.max(
0,
Math.min(
destination.cards.length - (source === destination ? 1 : 0),
Math.trunc(move.toIndex),
),
);
if (source === destination && index === target) return columns;
const next = columns.map((column) => ({
...column,
cards: [...column.cards],
}));
const from = next.find((column) => column.id === source.id)!;
const to = next.find((column) => column.id === destination.id)!;
const [card] = from.cards.splice(index, 1);
to.cards.splice(target, 0, card);
return next;
}
export interface KanbanProps<T extends KanbanCard = KanbanCard> {
label: string;
columns: KanbanColumn<T>[];
onColumnsChange?: (columns: KanbanColumn<T>[], move: KanbanMove) => void;
renderCard?: (card: T, column: KanbanColumn<T>) => ReactNode;
disabled?: boolean;
className?: string;
}
export function Kanban<T extends KanbanCard>({
label,
columns,
onColumnsChange,
renderCard,
disabled,
className,
}: KanbanProps<T>) {
const id = useId();
const [announcement, setAnnouncement] = useState("");
const cardIds = columns.flatMap((column) =>
column.cards.map((card) => card.id),
);
const invalid =
columns.some(
(column) => !column.id || column.cards.some((card) => !card.id),
) ||
new Set(columns.map((column) => column.id)).size !== columns.length ||
new Set(cardIds).size !== cardIds.length;
if (invalid)
return (
<p
role="alert"
className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive"
>
Kanban needs unique non-empty column and card IDs.
</p>
);
const readOnly = disabled || !onColumnsChange;
const move = (change: KanbanMove) => {
if (readOnly) return;
const next = moveKanbanCard(columns, change);
if (next === columns) return;
const focused = document.activeElement;
const cardElementId = `${id}-card-${encodeURIComponent(change.cardId)}`;
const restore =
focused instanceof HTMLElement &&
document.getElementById(cardElementId)?.contains(focused);
const focusLabel = focused?.getAttribute("aria-label");
onColumnsChange?.(next, change);
if (restore)
requestAnimationFrame(() => {
const element = document.getElementById(cardElementId);
const target = [
...(element?.querySelectorAll<HTMLElement>("[aria-label]") ?? []),
].find(
(control) =>
control.getAttribute("aria-label") === focusLabel &&
!(control as HTMLButtonElement).disabled,
);
(
target ??
element?.querySelector<HTMLElement>("select:not(:disabled)") ??
element
)?.focus();
});
const card = columns
.flatMap((column) => column.cards)
.find((item) => item.id === change.cardId);
const destination = next.find((column) => column.id === change.toColumnId)!;
setAnnouncement(
`${card?.title} moved to ${destination.title}, position ${destination.cards.findIndex((item) => item.id === change.cardId) + 1}.`,
);
};
return (
<section aria-label={label} className={cn("min-w-0 space-y-3", className)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{label}</h3>
<p className="text-xs text-muted-foreground">
{readOnly
? "Read-only board"
: "Drag a handle or use each card’s move controls"}
</p>
</div>
{!columns.length ? (
<p className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
No columns yet. Add a column to start your board.
</p>
) : (
<DragDropContext
onDragEnd={(result) => {
if (result.destination)
move({
cardId: result.draggableId,
fromColumnId: result.source.droppableId,
toColumnId: result.destination.droppableId,
toIndex: result.destination.index,
});
}}
>
<div
role="region"
aria-label={`${label} columns`}
tabIndex={0}
className="flex gap-4 overflow-x-auto rounded-xl pb-3 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{columns.map((column, columnIndex) => (
<div
key={column.id}
className="w-72 min-w-64 shrink-0 rounded-xl border bg-muted/35 p-3 sm:w-80"
role="group"
aria-labelledby={`${id}-column-${columnIndex}`}
>
<div className="mb-3 flex items-center justify-between gap-2 px-1">
<h4
id={`${id}-column-${columnIndex}`}
className="text-sm font-semibold"
>
{column.title}
</h4>
<span className="rounded-full bg-background px-2 py-0.5 text-xs tabular-nums">
{column.cards.length}
</span>
</div>
<Droppable
droppableId={column.id}
isDropDisabled={readOnly || column.disabled}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
"min-h-32 space-y-3 rounded-lg",
snapshot.isDraggingOver &&
"bg-primary/5 ring-2 ring-primary/30",
)}
>
{column.cards.map((card, cardIndex) => (
<Draggable
key={card.id}
draggableId={card.id}
index={cardIndex}
disableInteractiveElementBlocking
isDragDisabled={readOnly || column.disabled}
>
{(drag, dragging) => (
<article
id={`${id}-card-${encodeURIComponent(card.id)}`}
tabIndex={-1}
ref={drag.innerRef}
{...drag.draggableProps}
aria-label={card.title}
className={cn(
"min-w-0 rounded-xl border bg-card p-3 text-card-foreground",
dragging.isDragging &&
"shadow-xl ring-2 ring-primary",
)}
>
<div className="mb-2 flex items-start gap-2">
<button
type="button"
{...drag.dragHandleProps}
disabled={readOnly || column.disabled}
aria-label={`Drag ${card.title}`}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
<GripVertical className="size-4" />
</button>
<div className="min-w-0 flex-1">
{renderCard ? (
renderCard(card, column)
) : (
<>
<p className="text-sm font-medium">
{card.title}
</p>
{card.description && (
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{card.description}
</p>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-1 border-t pt-2">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Move ${card.title} up`}
disabled={
readOnly ||
column.disabled ||
cardIndex === 0
}
onClick={() =>
move({
cardId: card.id,
fromColumnId: column.id,
toColumnId: column.id,
toIndex: cardIndex - 1,
})
}
>
<ArrowUp className="size-3.5" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Move ${card.title} down`}
disabled={
readOnly ||
column.disabled ||
cardIndex === column.cards.length - 1
}
onClick={() =>
move({
cardId: card.id,
fromColumnId: column.id,
toColumnId: column.id,
toIndex: cardIndex + 1,
})
}
>
<ArrowDown className="size-3.5" />
</Button>
<select
aria-label={`Move ${card.title} to column`}
disabled={readOnly || column.disabled}
value={column.id}
className="ms-auto min-w-0 max-w-40 rounded-md border bg-background px-1 py-1 text-xs disabled:opacity-50"
onChange={(event) =>
move({
cardId: card.id,
fromColumnId: column.id,
toColumnId: event.target.value,
toIndex:
columns.find(
(item) =>
item.id === event.target.value,
)?.cards.length ?? 0,
})
}
>
{columns.map((target) => (
<option
key={target.id}
value={target.id}
disabled={target.disabled}
>
{target.title}
</option>
))}
</select>
</div>
</article>
)}
</Draggable>
))}
{provided.placeholder}
{!column.cards.length && (
<p className="rounded-lg border border-dashed p-5 text-center text-xs text-muted-foreground">
{readOnly || column.disabled
? "No cards in this column"
: "Drop a card here"}
</p>
)}
</div>
)}
</Droppable>
</div>
))}
</div>
</DragDropContext>
)}
<p role="status" className="text-xs text-muted-foreground">
{announcement ||
`${cardIds.length} cards across ${columns.length} columns.`}
</p>
</section>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible board title and accessible name. |
| columns | KanbanColumn<T>[]Default: required | Unique column id/title/cards with optional disabled state. Each card needs a globally unique id and title. |
| onColumnsChange | (nextColumns, move) => voidDefault: read-only | Parent commits the proposed immutable arrangement and may persist it. |
| renderCard | (card, column) => ReactNodeDefault: title / description | Extend card content without reimplementing movement. |
| disabled / className | boolean / stringDefault: false / — | Make the whole board read-only or extend its container. |
| moveKanbanCard | pure helperDefault: — | Calculate an immutable arrangement for valid moves; no network writes. |