Kanban Board
Reusable drag-and-drop kanban block powered by react-kanban-kit with shadcn cards, sprint mock data, and board helpers.
"use client";
import { Columns3, LayoutGrid, Lock, MoveHorizontal } from "lucide-react";import * as React from "react";import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Label } from "@/components/ui/label";import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from "@/components/ui/sheet";import { Switch } from "@/components/ui/switch";import { Toaster } from "@/components/ui/sonner";import { KanbanBoardPage, MOCK_SPRINT_BOARD_META, createSprintKanbanBoard, findKanbanColumnForCard, getKanbanBoardStats, getKanbanCardContent, type BoardData, type BoardItem,} from "@/components/ui/kanban-board";
export function KanbanBoardFull() { const [board, setBoard] = React.useState<BoardData>(() => createSprintKanbanBoard(), ); const [viewOnly, setViewOnly] = React.useState(false); const [allowColumnDrag, setAllowColumnDrag] = React.useState(false); const [selectedCard, setSelectedCard] = React.useState<BoardItem | null>( null, ); const [lastMove, setLastMove] = React.useState<string | null>(null);
const stats = React.useMemo(() => getKanbanBoardStats(board), [board]); const selectedContent = selectedCard ? getKanbanCardContent(selectedCard) : null; const selectedColumn = selectedCard ? findKanbanColumnForCard(board, selectedCard.id) : undefined;
return ( <> <Toaster richColors position="bottom-right" /> <div className="flex min-h-[calc(100vh-4rem)] w-full max-w-[90rem] flex-col gap-6 p-4 md:p-6"> <header className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="space-y-2"> <div className="flex flex-wrap items-center gap-2"> <LayoutGrid className="text-muted-foreground size-5" /> <h1 className="text-2xl font-semibold tracking-tight"> {MOCK_SPRINT_BOARD_META.sprintName} </h1> </div> <p className="text-muted-foreground max-w-2xl text-sm leading-relaxed"> {MOCK_SPRINT_BOARD_META.sprintGoal} </p> </div> <div className="flex flex-wrap gap-2"> <Badge variant="secondary" className="tabular-nums"> {stats.totalCards} cards </Badge> <Badge variant="outline" className="tabular-nums"> {stats.storyPoints} pts </Badge> <Badge variant="outline">{stats.totalColumns} columns</Badge> </div> </header>
<div className="bg-muted/40 flex flex-wrap items-center gap-4 rounded-lg border px-4 py-3 md:gap-6"> <div className="flex items-center gap-2"> <Switch id="kanban-view-only" checked={viewOnly} onCheckedChange={setViewOnly} /> <Label htmlFor="kanban-view-only" className="flex items-center gap-1.5" > <Lock className="size-3.5" /> View only </Label> </div> <div className="flex items-center gap-2"> <Switch id="kanban-column-drag" checked={allowColumnDrag} onCheckedChange={setAllowColumnDrag} /> <Label htmlFor="kanban-column-drag" className="flex items-center gap-1.5" > <Columns3 className="size-3.5" /> Reorder columns </Label> </div> <div className="bg-border hidden h-6 w-px md:block" /> <Button type="button" variant="outline" size="sm" onClick={() => { setBoard(createSprintKanbanBoard()); setLastMove(null); setSelectedCard(null); toast.message("Board reset", { description: "Restored mock sprint data.", }); }} > Reset mock data </Button> </div>
{lastMove ? ( <p className="text-muted-foreground flex items-center gap-2 text-xs"> <MoveHorizontal className="size-3.5" /> Last change: {lastMove} </p> ) : null}
<KanbanBoardPage board={board} onBoardChange={setBoard} onCardMove={(move) => { const card = board[move.cardId]; const fromCol = board[move.fromColumnId]; const toCol = board[move.toColumnId]; const label = `${card?.title ?? move.cardId}: ${fromCol?.title ?? "?"} → ${toCol?.title ?? "?"}`; setLastMove(label); toast.success("Card moved", { description: label }); }} viewOnly={viewOnly} allowColumnDrag={allowColumnDrag} cardsGap={10} pageTitle="" pageDescription="" onCardClick={(_event, card) => setSelectedCard(card)} rootClassName="pb-2" />
<Sheet open={Boolean(selectedCard)} onOpenChange={(open) => { if (!open) setSelectedCard(null); }} > <SheetContent className="sm:max-w-md"> <SheetHeader> <SheetTitle>{selectedCard?.title}</SheetTitle> <SheetDescription> {selectedColumn ? `Column: ${selectedColumn.title}` : "Card details"} </SheetDescription> </SheetHeader> {selectedCard && selectedContent ? ( <dl className="mt-6 grid gap-3 text-sm"> {selectedContent.description ? ( <div> <dt className="text-muted-foreground mb-1">Description</dt> <dd>{selectedContent.description}</dd> </div> ) : null} {selectedContent.priority ? ( <div> <dt className="text-muted-foreground mb-1">Priority</dt> <dd className="capitalize">{selectedContent.priority}</dd> </div> ) : null} {selectedContent.assignee ? ( <div> <dt className="text-muted-foreground mb-1">Assignee</dt> <dd>{selectedContent.assignee}</dd> </div> ) : null} {selectedContent.dueDate ? ( <div> <dt className="text-muted-foreground mb-1">Due</dt> <dd> <time dateTime={selectedContent.dueDate}> {selectedContent.dueDate} </time> </dd> </div> ) : null} {selectedContent.storyPoints !== undefined ? ( <div> <dt className="text-muted-foreground mb-1">Story points</dt> <dd>{selectedContent.storyPoints}</dd> </div> ) : null} {selectedContent.labels?.length ? ( <div> <dt className="text-muted-foreground mb-1">Labels</dt> <dd className="flex flex-wrap gap-1"> {selectedContent.labels.map((label) => ( <Badge key={label} variant="secondary"> {label} </Badge> ))} </dd> </div> ) : null} <div> <dt className="text-muted-foreground mb-1">Card id</dt> <dd className="font-mono text-xs">{selectedCard.id}</dd> </div> </dl> ) : null} </SheetContent> </Sheet> </div> </> );}Installation
Section titled “Installation”This installation provides support for all official Shadcn icon libraries. The component is otherwise identical to the non-experimental installation.
Icon support is experimental and may not be fully stable since it uses internal Shadcn APIs.
Install the following dependencies.
Copy and paste the following code into your project.
"use client";
import "../styles/kanban-board.css";
import { Kanban, dropHandler, type BoardData, type BoardItem,} from "react-kanban-kit";import { GripVertical } from "lucide-react";import * as React from "react";
import { Badge } from "@/components/ui/badge";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";import { cn } from "@/lib/utils";import type { KanbanCardContent, KanbanCardPriority,} from "../lib/kanban-board-types";import type { CardRenderProps, ConfigMap, DropCardParams,} from "../lib/kanban-kit-types";
export type { BoardData, BoardItem, DropCardParams };export type { CardRenderProps, ConfigMap } from "../lib/kanban-kit-types";export type { KanbanCardContent, KanbanCardPriority,} from "../lib/kanban-board-types";
export type KanbanCardRenderProps = CardRenderProps & { data: BoardItem & { content?: KanbanCardContent };};
export interface KanbanBoardPageProps { /** Normalized board graph (root → columns → cards). */ board: BoardData; className?: string; pageTitle?: string; pageDescription?: string; viewOnly?: boolean; allowColumnDrag?: boolean; cardsGap?: number; onBoardChange?: (board: BoardData) => void; /** Fired after a card drag with the move payload and updated board (before `onBoardChange`). */ onCardMove?: (move: DropCardParams, nextBoard: BoardData) => void; onCardClick?: ( event: React.MouseEvent<HTMLDivElement>, card: BoardItem, ) => void; /** Override the default shadcn card renderer. */ renderCard?: (props: KanbanCardRenderProps) => React.ReactNode; /** Override column headers (receives column item + card count). */ renderColumnHeader?: ( column: BoardItem, cardCount: number, ) => React.ReactNode; rootClassName?: string;}
const PRIORITY_LABEL: Record<KanbanCardPriority, string> = { low: "Low", medium: "Medium", high: "High",};
function priorityBadgeClass(priority: KanbanCardPriority): string { switch (priority) { case "high": return "bg-destructive/10 text-destructive border-destructive/20"; case "medium": return "bg-primary/10 text-primary border-primary/20"; default: return "bg-muted text-muted-foreground border-border"; }}
export function applyCardMove( move: DropCardParams, board: BoardData,): BoardData { return dropHandler( { cardId: move.cardId, fromColumnId: move.fromColumnId, toColumnId: move.toColumnId, taskAbove: move.taskAbove, taskBelow: move.taskBelow, }, board, ) as BoardData;}
/** Small 3-column board for minimal examples and quick tests. */export function createDemoKanbanBoard(): BoardData { return structuredClone({ root: { id: "root", title: "Root", children: ["col-todo", "col-progress", "col-done"], totalChildrenCount: 3, parentId: null, }, "col-todo": { id: "col-todo", title: "To Do", children: ["task-1", "task-2"], totalChildrenCount: 2, parentId: "root", }, "col-progress": { id: "col-progress", title: "In Progress", children: ["task-3"], totalChildrenCount: 1, parentId: "root", }, "col-done": { id: "col-done", title: "Done", children: ["task-4"], totalChildrenCount: 1, parentId: "root", }, "task-1": { id: "task-1", title: "Design homepage", parentId: "col-todo", children: [], totalChildrenCount: 0, type: "card", content: { description: "Wireframes and mockups for the marketing homepage.", priority: "high", labels: ["Design"], }, }, "task-2": { id: "task-2", title: "Set up database", parentId: "col-todo", children: [], totalChildrenCount: 0, type: "card", content: { description: "Provision D1 and run initial migrations.", priority: "medium", }, }, "task-3": { id: "task-3", title: "Build auth flow", parentId: "col-progress", children: [], totalChildrenCount: 0, type: "card", content: { description: "Better Auth session + protected routes.", priority: "high", labels: ["Backend"], }, }, "task-4": { id: "task-4", title: "Deploy to production", parentId: "col-done", children: [], totalChildrenCount: 0, type: "card", content: { priority: "low", labels: ["DevOps"], }, }, });}
export { MOCK_SPRINT_BOARD, MOCK_SPRINT_BOARD_META, createSprintKanbanBoard,} from "../data/mock-sprint-board";export { findKanbanColumnForCard, getKanbanBoardStats, getKanbanCardContent, getKanbanCards, getKanbanColumn, getKanbanColumnIds, type KanbanBoardStats,} from "../lib/board-utils";
function DefaultKanbanCard({ data }: KanbanCardRenderProps) { const content = (data.content ?? {}) as KanbanCardContent;
return ( <Card className="wox-kanban-card gap-0 py-0 shadow-sm"> <CardHeader className="flex flex-row items-start gap-2 space-y-0 px-3 pt-3 pb-2"> <GripVertical className="text-muted-foreground mt-0.5 size-4 shrink-0" aria-hidden /> <CardTitle className="text-sm leading-snug font-medium"> {data.title} </CardTitle> </CardHeader> {(content.description || content.priority || (content.labels?.length ?? 0) > 0 || content.assignee || content.dueDate || content.storyPoints !== undefined) && ( <CardContent className="flex flex-col gap-2 px-3 pt-0 pb-3"> {content.description ? ( <p className="text-muted-foreground text-xs leading-relaxed"> {content.description} </p> ) : null} <div className="flex flex-wrap items-center gap-1.5"> {content.priority ? ( <Badge variant="outline" className={cn( "text-[10px] font-medium uppercase", priorityBadgeClass(content.priority), )} > {PRIORITY_LABEL[content.priority]} </Badge> ) : null} {content.labels?.map((label) => ( <Badge key={label} variant="secondary" className="text-[10px]"> {label} </Badge> ))} </div> {(content.assignee || content.dueDate || content.storyPoints !== undefined) && ( <div className="text-muted-foreground flex items-center justify-between gap-2 border-t pt-2 text-[11px]"> <span className="flex items-center gap-1.5"> {content.assigneeInitials ? ( <span className="bg-primary/15 text-primary flex size-5 items-center justify-center rounded-full text-[9px] font-semibold"> {content.assigneeInitials} </span> ) : null} {content.assignee ? ( <span className="truncate">{content.assignee}</span> ) : null} </span> <span className="flex shrink-0 items-center gap-2 tabular-nums"> {content.dueDate ? ( <time dateTime={content.dueDate}>{content.dueDate}</time> ) : null} {content.storyPoints !== undefined ? ( <span>{content.storyPoints} pt</span> ) : null} </span> </div> )} </CardContent> )} </Card> );}
function DefaultColumnHeader({ column, cardCount,}: { column: BoardItem; cardCount: number;}) { return ( <div className="wox-kanban-column__header flex items-center justify-between gap-2"> <h3 className="text-sm font-semibold tracking-tight">{column.title}</h3> <Badge variant="secondary" className="tabular-nums"> {cardCount} </Badge> </div> );}
export function KanbanBoardPage({ board, className, pageTitle = "Kanban board", pageDescription = "Drag cards between columns. Wire onBoardChange to persist moves.", viewOnly = false, allowColumnDrag = false, cardsGap = 8, onBoardChange, onCardMove, onCardClick, renderCard, renderColumnHeader, rootClassName,}: KanbanBoardPageProps) { const handleCardMove = React.useCallback( (move: DropCardParams) => { const next = applyCardMove(move, board); onCardMove?.(move, next); onBoardChange?.(next); }, [board, onBoardChange, onCardMove], );
const configMap = React.useMemo<ConfigMap>( () => ({ card: { render: (props: CardRenderProps) => renderCard ? ( renderCard(props as KanbanCardRenderProps) ) : ( <DefaultKanbanCard {...(props as KanbanCardRenderProps)} /> ), isDraggable: !viewOnly, }, }), [renderCard, viewOnly], );
return ( <section className={cn("wox-kanban-board flex w-full flex-col gap-4", className)} > {(pageTitle?.trim() || pageDescription?.trim()) && ( <header className="space-y-1"> {pageTitle ? ( <h2 className="text-lg font-semibold tracking-tight"> {pageTitle} </h2> ) : null} {pageDescription ? ( <p className="text-muted-foreground text-sm">{pageDescription}</p> ) : null} </header> )}
<div className="wox-kanban-board__surface overflow-x-auto rounded-xl border bg-background p-3"> <Kanban dataSource={board} configMap={configMap} viewOnly={viewOnly} allowColumnDrag={allowColumnDrag} cardsGap={cardsGap} onCardMove={viewOnly ? undefined : handleCardMove} onCardClick={onCardClick} rootClassName={cn("min-w-max", rootClassName)} renderColumnHeader={(column) => { const count = column.children.length; return renderColumnHeader ? ( renderColumnHeader(column, count) ) : ( <DefaultColumnHeader column={column} cardCount={count} /> ); }} renderColumnWrapper={( column, { children, className: colClass, style }, ) => ( <div className={cn("wox-kanban-column", colClass)} style={style} data-column-id={column.id} > {children} </div> )} columnListContentClassName={() => "wox-kanban-column__list"} /> </div> </section> );}import type { BoardData } from "react-kanban-kit";
type CardSeed = { id: string; title: string; columnId: string; content?: { description?: string; priority?: "low" | "medium" | "high"; labels?: string[]; assignee?: string; assigneeInitials?: string; dueDate?: string; storyPoints?: number; };};
const COLUMNS = [ { id: "col-backlog", title: "Backlog" }, { id: "col-todo", title: "To Do" }, { id: "col-progress", title: "In Progress" }, { id: "col-review", title: "In Review" }, { id: "col-done", title: "Done" },] as const;
const CARDS: CardSeed[] = [ { id: "vr-101", columnId: "col-backlog", title: "Vendor request queue handler", content: { description: "Consume VERIFICATION_REQUEST_CREATED, persist vendor_requests, emit PUSH_NOTIFICATION.", priority: "high", labels: ["Backend", "Verifyman"], assignee: "Sara K.", assigneeInitials: "SK", dueDate: "2026-05-28", storyPoints: 8, }, }, { id: "vr-102", columnId: "col-backlog", title: "Push service fan-out cleanup", content: { description: "Remove VR-specific logic from push-services; generic PUSH_NOTIFICATION only.", priority: "medium", labels: ["Refactor"], assignee: "Alex M.", assigneeInitials: "AM", dueDate: "2026-05-30", storyPoints: 5, }, }, { id: "vr-103", columnId: "col-backlog", title: "Document primary-queue routing", content: { priority: "low", labels: ["Docs"], storyPoints: 2, }, }, { id: "vr-201", columnId: "col-todo", title: "Kanban board registry block", content: { description: "react-kanban-kit + shadcn cards, Storybook full example, mock data.", priority: "high", labels: ["woxcn-registry", "UI"], assignee: "Jordan P.", assigneeInitials: "JP", dueDate: "2026-05-24", storyPoints: 5, }, }, { id: "vr-202", columnId: "col-todo", title: "Social schedule calendar QA", content: { description: "Verify schedule sheet CRUD and platform colors on staging.", priority: "medium", labels: ["QA"], assignee: "Mina L.", assigneeInitials: "ML", dueDate: "2026-05-25", storyPoints: 3, }, }, { id: "vr-203", columnId: "col-todo", title: "CI affected-package filter", content: { priority: "medium", labels: ["DevOps"], assignee: "Alex M.", assigneeInitials: "AM", storyPoints: 3, }, }, { id: "vr-301", columnId: "col-progress", title: "Consent reminder cron", content: { description: "Daily 9:00 UTC job; max 3 reminders per consent.", priority: "high", labels: ["Cron", "Email"], assignee: "Sara K.", assigneeInitials: "SK", dueDate: "2026-05-23", storyPoints: 5, }, }, { id: "vr-302", columnId: "col-progress", title: "Authentication OTP email routing", content: { description: "Local → email-staging; staging/prod → EMAIL_SERVICE binding.", priority: "high", labels: ["Auth"], assignee: "Jordan P.", assigneeInitials: "JP", dueDate: "2026-05-23", storyPoints: 3, }, }, { id: "vr-401", columnId: "col-review", title: "Marketing site SEO pass", content: { description: "Update index.html meta, OG tags, remove Lovable placeholders.", priority: "medium", labels: ["Marketing"], assignee: "Mina L.", assigneeInitials: "ML", dueDate: "2026-05-22", storyPoints: 3, }, }, { id: "vr-402", columnId: "col-review", title: "Manpower invoice block", content: { priority: "low", labels: ["woxcn-registry"], assignee: "Jordan P.", assigneeInitials: "JP", storyPoints: 2, }, }, { id: "vr-501", columnId: "col-done", title: "Release please manifest bump", content: { priority: "low", labels: ["Release"], assignee: "Alex M.", assigneeInitials: "AM", storyPoints: 1, }, }, { id: "vr-502", columnId: "col-done", title: "D1 migration vendor-request", content: { description: "Applied migrations on staging; seed verified.", priority: "medium", labels: ["Database"], assignee: "Sara K.", assigneeInitials: "SK", storyPoints: 2, }, }, { id: "vr-503", columnId: "col-done", title: "Storybook deploy pipeline", content: { priority: "low", labels: ["DevOps"], storyPoints: 1, }, },];
function buildBoard(cards: CardSeed[]): BoardData { const columnChildren = new Map<string, string[]>(); for (const col of COLUMNS) { columnChildren.set(col.id, []); } for (const card of cards) { columnChildren.get(card.columnId)?.push(card.id); }
const board: BoardData = { root: { id: "root", title: "Sprint 24", children: COLUMNS.map((c) => c.id), totalChildrenCount: COLUMNS.length, parentId: null, }, };
for (const col of COLUMNS) { const children = columnChildren.get(col.id) ?? []; board[col.id] = { id: col.id, title: col.title, children, totalChildrenCount: children.length, parentId: "root", }; }
for (const card of cards) { board[card.id] = { id: card.id, title: card.title, parentId: card.columnId, children: [], totalChildrenCount: 0, type: "card", content: card.content, }; }
return board;}
/** Full sprint board for Storybook, docs previews, and local prototyping. */export const MOCK_SPRINT_BOARD: BoardData = buildBoard(CARDS);
/** Returns a deep clone so drag-and-drop mutations do not affect the module singleton. */export function createSprintKanbanBoard(): BoardData { return structuredClone(MOCK_SPRINT_BOARD);}
export const MOCK_SPRINT_BOARD_META = { sprintName: "Sprint 24 — Verifyman platform", sprintGoal: "Ship vendor-request queue routing, registry kanban block, and staging QA for consent + auth.", columnIds: COLUMNS.map((c) => c.id), columnTitles: Object.fromEntries(COLUMNS.map((c) => [c.id, c.title])), cardCount: CARDS.length,} as const;import type { BoardData, BoardItem } from "react-kanban-kit";
import type { KanbanCardContent } from "./kanban-board-types";
export interface KanbanBoardStats { totalCards: number; totalColumns: number; cardsByColumn: Record<string, number>; storyPoints: number;}
/** Column ids in board order (from `root.children`). */export function getKanbanColumnIds(board: BoardData): string[] { return board.root?.children ?? [];}
export function getKanbanColumn( board: BoardData, columnId: string,): BoardItem | undefined { return board[columnId];}
export function getKanbanCards(board: BoardData): BoardItem[] { return Object.values(board).filter((item) => item.type === "card");}
export function getKanbanCardContent(card: BoardItem): KanbanCardContent { return (card.content ?? {}) as KanbanCardContent;}
export function getKanbanBoardStats(board: BoardData): KanbanBoardStats { const columnIds = getKanbanColumnIds(board); const cards = getKanbanCards(board); const cardsByColumn: Record<string, number> = {};
for (const columnId of columnIds) { cardsByColumn[columnId] = board[columnId]?.children.length ?? 0; }
const storyPoints = cards.reduce((sum, card) => { const content = getKanbanCardContent(card); return sum + (content.storyPoints ?? 0); }, 0);
return { totalCards: cards.length, totalColumns: columnIds.length, cardsByColumn, storyPoints, };}
export function findKanbanColumnForCard( board: BoardData, cardId: string,): BoardItem | undefined { const card = board[cardId]; if (!card?.parentId) return undefined; return getKanbanColumn(board, card.parentId);}/* Theme overrides for react-kanban-kit inside shadcn apps */
.wox-kanban-board { --wox-kanban-column-width: 18rem; --wox-kanban-board-gap: 1rem;}
.wox-kanban-board .wox-kanban-board__surface { min-height: 28rem;}
.wox-kanban-board .rkk-board { gap: var(--wox-kanban-board-gap); padding-bottom: 0.5rem; height: auto;}
.wox-kanban-board .rkk-column-outer { min-width: var(--wox-kanban-column-width); max-width: var(--wox-kanban-column-width);}
.wox-kanban-board .rkk-column { background: var(--muted) !important; border: 1px solid var(--border); border-radius: var(--radius-lg);}
.wox-kanban-board .wox-kanban-column { display: flex; flex-direction: column; max-height: min(70vh, 40rem);}
.wox-kanban-board .wox-kanban-column__header { flex-shrink: 0; padding: 0.75rem 0.75rem 0.5rem;}
.wox-kanban-board .wox-kanban-column__list { flex: 1; min-height: 0; padding: 0 0.5rem 0.5rem;}
.wox-kanban-board .wox-kanban-card { cursor: grab; transition: box-shadow 0.15s ease;}
.wox-kanban-board .wox-kanban-card:active { cursor: grabbing;}
.wox-kanban-board .wox-kanban-card:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px;}Update the import paths to match your project setup.
Installs:
components/blocks/kanban-board.tsx—KanbanBoardPageand helperslib/kanban-board-mock-data.ts— sprint mock graph (MOCK_SPRINT_BOARD,createSprintKanbanBoard)lib/kanban-board-utils.ts— stats and column/card lookupsstyles/kanban-board.css— theme overrides for react-kanban-kit
npx shadcn@latest add https://woxcn-registry.woxware.io/r/kanban-board.json@import "@/styles/kanban-board.css";Full working example
Section titled “Full working example”The Storybook Full board story and docs preview use the sprint mock (5 columns, 13 cards) with:
- Drag-and-drop between columns (
onBoardChange+applyCardMove) - Click a card → detail sheet (
onCardClick) - Toast on move (
onCardMove) - Toggles for
viewOnlyandallowColumnDrag - Reset mock data →
createSprintKanbanBoard()
"use client";
import { useState } from "react";import { toast } from "sonner";import { KanbanBoardPage, createSprintKanbanBoard, getKanbanCardContent, findKanbanColumnForCard, type BoardData, type BoardItem,} from "@/components/blocks/kanban-board";
export default function SprintBoardPage() { const [board, setBoard] = useState<BoardData>(() => createSprintKanbanBoard()); const [selected, setSelected] = useState<BoardItem | null>(null);
return ( <> <KanbanBoardPage board={board} onBoardChange={setBoard} onCardMove={(move) => { const card = board[move.cardId]; const from = board[move.fromColumnId]?.title; const to = board[move.toColumnId]?.title; toast.success(`${card?.title}: ${from} → ${to}`); }} onCardClick={(_e, card) => setSelected(card)} allowColumnDrag cardsGap={10} pageTitle="Sprint 24" pageDescription="Drag cards, click for details, persist onBoardChange to your API." /> {/* Render your own Sheet / Dialog using getKanbanCardContent(selected) */} </> );}Mock data
Section titled “Mock data”| Export | Description |
|---|---|
MOCK_SPRINT_BOARD | Read-only reference graph (do not mutate in place) |
createSprintKanbanBoard() | structuredClone of the mock — use as initial state |
MOCK_SPRINT_BOARD_META | sprintName, sprintGoal, columnIds, cardCount |
createDemoKanbanBoard() | Small 3-column / 4-card board for minimal demos |
Columns in the sprint mock: Backlog, To Do, In Progress, In Review, Done.
Board utilities
Section titled “Board utilities”| Function | Returns |
|---|---|
getKanbanColumnIds(board) | Column ids from root.children |
getKanbanColumn(board, id) | Column BoardItem |
getKanbanCards(board) | All items with type: "card" |
getKanbanCardContent(card) | Typed content object |
getKanbanBoardStats(board) | { totalCards, totalColumns, cardsByColumn, storyPoints } |
findKanbanColumnForCard(board, cardId) | Parent column for a card |
applyCardMove(move, board) | New BoardData after a drag |
Custom cards
Section titled “Custom cards”<KanbanBoardPage board={board} onBoardChange={setBoard} renderCard={({ data }) => ( <div className="rounded-md border bg-card p-3 text-sm shadow-sm"> {data.title} </div> )}/>Custom column headers
Section titled “Custom column headers”<KanbanBoardPage board={board} onBoardChange={setBoard} renderColumnHeader={(column, count) => ( <div className="flex justify-between px-1"> <span className="font-semibold">{column.title}</span> <span className="text-muted-foreground text-xs">{count}</span> </div> )}/>| Prop | Type | Default | Description |
|---|---|---|---|
board | BoardData | — | Required. Controlled board graph |
onBoardChange | (board: BoardData) => void | — | Called after drag with the updated graph |
onCardMove | (move: DropCardParams, nextBoard: BoardData) => void | — | Called on drag with move payload and next board (before onBoardChange) |
onCardClick | (event, card: BoardItem) => void | — | Card click (e.g. open detail sheet) |
className | string | — | Root <section> classes |
pageTitle | string | "Kanban board" | Heading; omit or pass "" to hide |
pageDescription | string | (see component) | Subtitle; omit or pass "" to hide |
viewOnly | boolean | false | Disables card drag |
allowColumnDrag | boolean | false | Enables column reordering |
cardsGap | number | 8 | Pixel gap between cards (library) |
renderCard | (props: KanbanCardRenderProps) => ReactNode | shadcn card | Custom card renderer |
renderColumnHeader | (column, cardCount) => ReactNode | title + count badge | Custom column header |
rootClassName | string | — | Classes on the library board root |
KanbanCardContent (card content field)
Section titled “KanbanCardContent (card content field)”type KanbanCardPriority = "low" | "medium" | "high";
interface KanbanCardContent { description?: string; priority?: KanbanCardPriority; labels?: string[]; assignee?: string; assigneeInitials?: string; // e.g. "SK" — shown in avatar chip dueDate?: string; // ISO YYYY-MM-DD storyPoints?: number;}Board graph (react-kanban-kit)
Section titled “Board graph (react-kanban-kit)”interface BoardData { root: BoardItem; [id: string]: BoardItem;}
interface BoardItem { id: string; title: string; parentId: string | null; children: string[]; totalChildrenCount: number; type?: "card"; // cards only content?: KanbanCardContent;}root.children— ordered column ids- Each column’s
children— ordered card ids in that column - Cards use
type: "card"andconfigMap.cardin the library
DropCardParams (drag payload)
Section titled “DropCardParams (drag payload)”interface DropCardParams { cardId: string; fromColumnId: string; toColumnId: string; taskAbove: string | null; taskBelow: string | null; position: number;}Re-exports
Section titled “Re-exports”From react-kanban-kit: BoardData, BoardItem, DropCardParams.
From this block: applyCardMove, createDemoKanbanBoard, createSprintKanbanBoard, MOCK_SPRINT_BOARD, MOCK_SPRINT_BOARD_META, and board utility functions listed above.