A responsive task board with pointer drag and keyboard movement between columns.
Planned
2Review onboarding
Check the first-run flow
DesignUpdate docs
ContentIn progress
1Build dashboard
Activity and metrics
EngineeringDone
1Define tokens
DesignDrag a card or use its arrow buttons. Alt + arrow keys also move a focused card.
import { KanbanBoard } from "@/components/ballmac/kanban-board"
const columns = [
{
id: "planned",
title: "Planned",
cards: [
{
id: "a",
title: "Review onboarding",
description: "Check the first-run flow",
label: "Design",
},
{ id: "b", title: "Update docs", label: "Content" },
],
},
{
id: "progress",
title: "In progress",
cards: [
{
id: "c",
title: "Build dashboard",
description: "Activity and metrics",
label: "Engineering",
},
],
},
{
id: "done",
title: "Done",
cards: [{ id: "d", title: "Define tokens", label: "Design" }],
},
]
export default function KanbanBoardDemo() {
return (
<KanbanBoard
className="w-full max-w-2xl"
defaultColumns={columns}
label="Product work board"
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/kanban-boardInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/directionCopy the source into your project.
components/ballmac/kanban-board.tsx// Ballmac UI: Kanban Board. https://ui.ballmac.com/components/kanban-board "use client" import * as React from "react" import { ArrowLeft, ArrowRight, GripVertical } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" import { useDirection } from "@/lib/ballmac/direction" type KanbanCard = { /** Stable card ID. */ id: string /** Card title. */ title: string /** Optional supporting detail. */ description?: string /** Optional short category. */ label?: string } type KanbanColumn = { /** Stable column ID. */ id: string /** Column title. */ title: string /** Cards in display order. */ cards: KanbanCard[] } type KanbanBoardProps = Omit<React.ComponentProps<"div">, "defaultValue"> & { /** Controlled board state. */ columns?: KanbanColumn[] /** Initial board state. */ defaultColumns?: KanbanColumn[] /** Called after a card moves. */ onColumnsChange?: (columns: KanbanColumn[]) => void /** Accessible name for the board. */ label?: string } function KanbanBoard({ className, columns, defaultColumns = [], onColumnsChange, label, ...props }: KanbanBoardProps) { const dir = useDirection() const msg = useMessages() label ??= msg("kanban-board.label", "Task board") const [internal, setInternal] = React.useState(defaultColumns) const [dragging, setDragging] = React.useState<string | null>(null) const [announcement, setAnnouncement] = React.useState("") const current = columns ?? internal const cardRefs = React.useRef(new Map<string, HTMLElement>()) function move(cardId: string, targetIndex: number, offset?: number) { const sourceIndex = current.findIndex((column) => column.cards.some((card) => card.id === cardId), ) if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= current.length) return const sourceCardIndex = current[sourceIndex].cards.findIndex( (card) => card.id === cardId, ) const next = current.map((column) => ({ ...column, cards: [...column.cards], })) const [card] = next[sourceIndex].cards.splice(sourceCardIndex, 1) const insertion = sourceIndex === targetIndex ? Math.max( 0, Math.min( next[targetIndex].cards.length, sourceCardIndex + (offset ?? 0), ), ) : next[targetIndex].cards.length next[targetIndex].cards.splice(insertion, 0, card) if (columns === undefined) setInternal(next) onColumnsChange?.(next) setAnnouncement(`${card.title} moved to ${next[targetIndex].title}`) if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => cardRefs.current.get(cardId)?.focus()) else cardRefs.current.get(cardId)?.focus() } return ( <div data-slot="kanban-board" role="region" aria-label={label} tabIndex={0} className={cn( "max-w-full overflow-x-auto rounded-xl border border-border bg-muted/30 p-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className, )} {...props} > <div className="flex min-w-max gap-3"> {current.map((column, columnIndex) => ( <section key={column.id} data-slot="kanban-column" aria-label={column.title} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault() if (dragging) move(dragging, columnIndex) setDragging(null) }} className="bg-card w-60 shrink-0 rounded-lg border border-border p-3 shadow-sm" > <div className="mb-3 flex items-center justify-between gap-2"> <h3 className="text-sm font-semibold">{column.title}</h3> <span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs tabular-nums"> {column.cards.length} </span> </div> <div className="flex min-h-16 flex-col gap-2"> {column.cards.map((card) => ( <article key={card.id} ref={(element) => { if (element) cardRefs.current.set(card.id, element) else cardRefs.current.delete(card.id) }} data-slot="kanban-card" draggable tabIndex={0} aria-label={msg("kanban-board.useAltAndArrowKeys", "{title}, {column}. Use Alt and arrow keys to move.", { title: card.title, column: column.title })} onDragStart={(event) => { setDragging(card.id) event.dataTransfer.effectAllowed = "move" event.dataTransfer.setData("text/plain", card.id) }} onDragEnd={() => setDragging(null)} onKeyDown={(event) => { if (!event.altKey) return if (event.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) move(card.id, columnIndex - 1) else if (event.key === (dir === "rtl" ? "ArrowLeft" : "ArrowRight")) move(card.id, columnIndex + 1) else if (event.key === "ArrowUp") move(card.id, columnIndex, -1) else if (event.key === "ArrowDown") move(card.id, columnIndex, 1) else return event.preventDefault() }} className="group/kanban-card bg-background rounded-md border border-border p-3 shadow-sm outline-none transition-[border-color,box-shadow] duration-150 hover:border-ring/50 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none" > <div className="flex items-start gap-2"> <GripVertical aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <div className="min-w-0 flex-1"> <p className="text-sm font-medium leading-5"> {card.title} </p> {card.description && ( <p className="text-muted-foreground mt-1 text-xs leading-relaxed"> {card.description} </p> )} {card.label && ( <span className="bg-secondary text-secondary-foreground mt-2 inline-block rounded-full px-2 py-0.5 text-xs"> {card.label} </span> )} </div> </div> <div className="mt-3 flex items-center justify-end gap-1 border-t border-border pt-2"> <button type="button" aria-label={msg("kanban-board.moveToPreviousColumn", "Move {title} to previous column", { title: card.title })} disabled={columnIndex === 0} onClick={() => move(card.id, columnIndex - 1)} className="text-muted-foreground hover:bg-accent hover:text-foreground flex size-7 items-center justify-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40" > <ArrowLeft aria-hidden="true" className="size-3.5 rtl:rotate-180" /> </button> <button type="button" aria-label={msg("kanban-board.moveToNextColumn", "Move {title} to next column", { title: card.title })} disabled={columnIndex === current.length - 1} onClick={() => move(card.id, columnIndex + 1)} className="text-muted-foreground hover:bg-accent hover:text-foreground flex size-7 items-center justify-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40" > <ArrowRight aria-hidden="true" className="size-3.5 rtl:rotate-180" /> </button> </div> </article> ))} </div> </section> ))} </div> <p className="text-muted-foreground mt-3 text-xs"> Drag a card or use its arrow buttons. Alt + arrow keys also move a focused card. </p> <span className="sr-only" aria-live="polite"> {announcement} </span> </div> ) } export { KanbanBoard, type KanbanBoardProps, type KanbanColumn, type KanbanCard, }Update the import paths to match your project setup.
Usage
import { KanbanBoard } from "@/components/ballmac/kanban-board"The full example is in the Code tab above.
Examples
States and variants
To do
1Prepare release
Done
0Drag a card or use its arrow buttons. Alt + arrow keys also move a focused card.
import { KanbanBoard } from "@/components/ballmac/kanban-board"
export default function KanbanBoardStates() {
return (
<KanbanBoard
className="w-full max-w-md"
defaultColumns={[
{
id: "todo",
title: "To do",
cards: [{ id: "task", title: "Prepare release" }],
},
{ id: "done", title: "Done", cards: [] },
]}
label="Release board"
/>
)
}API reference
| Prop | Type | Default |
|---|---|---|
columnsControlled board state. | KanbanColumn[] | — |
defaultColumnsInitial board state. | KanbanColumn[] | [] |
onColumnsChangeCalled after a card moves. | (columns: KanbanColumn[]) => void | — |
labelAccessible name for the board. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab / Alt + Arrow keys | Focuses and moves cards; buttons also move between columns |
Use with AI
A responsive task board with pointer drag and keyboard movement between columns. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Kanban Board (@ballmac/kanban-board) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Organize tasks across workflow stages
Not for
- Use a table for dense sortable records
Registry JSON: https://ui.ballmac.com/r/kanban-board.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Audio Player
A podcast-style audio player: play and pause, skip back and forward, a scrubber with chapter ticks, a named current chapter and a speed control. Works with a real file or as a silent demo.
Avatar Circles
Overlapping avatars that spread on hover, lift individually with a name and role tooltip, show presence, fall back to initials and end in a +N count that can be a button.