Board
Kanban columns over the drag-reorder seam — pointer drag, keyboard move mode, a lossless per-card Move menu, server-refusable moves, collapsed lanes.
- Status
- Since
0.4.0- Accessibility pattern
- keyboard-first drag alternative
Last updated
Install
Add Board from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/boardThe same command installs the registry items it composes: @vegastack/badge, @vegastack/button, @vegastack/card, @vegastack/drag-item, @vegastack/dropdown-menu, @vegastack/empty, @vegastack/icon-button, @vegastack/scroll-area, @vegastack/use-drag-reorder, @vegastack/use-mobile.
Usage
import { Board } from "@/components/ui/board";
<Board
aria-label="Deals"
columns={stages.map((s) => ({ id: s.id, title: s.label, items: s.deals }))}
getItemId={(deal) => deal.id}
renderCard={(deal) => (
<>
{deal.name} · {deal.amount}
</>
)}
onMove={({ id, to }) => api.moveDeal(id, to.container, to.index)}
/>;The split is content/chrome: you render card content only; the board owns
the column shells, counts, empty drop targets, collapsed strips, drag and
keyboard models, and the pending/rejected affordances. onMove is the single
move command for every input path — drag, keyboard move mode, and the menu.
Scope
| Behaviour | Where it lives |
|---|---|
| Ordering + persistence | Host — onMove is a request; reject its promise to refuse a move |
| Card content | Host, via renderCard — the board owns all chrome |
| Single-list reordering | SortableList |
| Column model / stages | Host data — columns are props, never fetched |
| Virtualization | Deliberately none — columns are bounded at this component's scale |
Examples
Server-gated moves
Return a promise from onMove: the card shimmers in place while the write is
in flight, and a rejection announces the snap-back — the host never applied
the move, so the card simply stays.
Locked lanes, the keyboard path, and column height
A column with droppable: false renders as an inert drop target that states its lockedReason
rather than silently refusing. Every card's "Move card" menu is the lossless keyboard and
assistive-tech equivalent of a drag, and it stays available for lanes the pointer path refuses —
which is also why the grab cursor appears only where a pointer drag can actually start (not under
dragDisabled, not below the mobile breakpoint, not in readOnly).
columnMaxHeight caps the scrolling card list. It defaults to the shared overlay ceiling token; a
board inside a shorter shell passes its own CSS length instead of the component assuming a viewport
reservation.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
columns* | readonly BoardColumn<T>[] | — | Columns in display order. |
getItemId* | (item: T) => string | — | Stable card identity. |
onMove* | (move: DragReorderMove) => void | Promise<void> | — | Apply a move (drag, keyboard, or menu). Return a promise for server-gated moves: the card shimmers in place while pending, and a rejection announces the snap-back (the host never applied it). |
renderCard* | (item: T, column: BoardColumn<T>) => React.ReactNode | — | Render a card's CONTENT only — the board owns the card chrome (surface, border, focus, drag affordances). |
aria-label | string | "Board" | Accessible name for the board. |
className | string | — | Extra classes for the board root. |
columnMaxHeight | string | "var(--layout-overlay-max-height)" | Maximum height of a column's scrolling card list, as a CSS length, applied
through the --board-column-max-height custom property. The default is the
shared overlay ceiling token — a board inside a shorter shell passes its own
length rather than the component assuming a viewport reservation. |
columnWidth | string | "18rem" | Column width as a CSS length, applied through the --board-column-width
custom property. |
dragDisabled | boolean | false | Force-disable dragging (the menu and move mode remain). Dragging is always disabled below 768px. |
onCardActivate | ((item: T) => void) | — | Activate a card (open its record). Cards render as real buttons. |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the board root (data-slot="board"). |
renderColumnAction | ((column: BoardColumn<T>) => React.ReactNode) | — | Extra per-column header action (a filter menu, an add button) rendered in
the column's CardAction seat. |
Data attributes and CSS variables on Board
| Attribute | Values |
|---|---|
data-column | mirrors a prop or state value |
data-drag-item | mirrors a prop or state value |
data-drag-pending | mirrors a prop or state value |
data-dragging | mirrors a prop or state value |
data-drop-edge | mirrors a prop or state value |
data-drop-over | mirrors a prop or state value |
data-read-only | "" |
data-slot | "board" | "board-card" | "board-card-surface" | "board-column" | "board-column-body" | "board-column-collapsed" | "board-column-collapsed-title" | "board-column-empty" | "board-column-title" | "board-scroller" |
--board-column-max-height | CSS custom property |
--board-column-width | CSS custom property |
Column
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable column id — the container identity moves target. |
items* | readonly T[] | — | Cards in display order (controlled). |
title* | React.ReactNode | — | Column heading content. |
collapsed | boolean | false | Render collapsed to a narrow strip (terminal columns). Activating the strip expands the column read-only: cards show but do not drag, and it is not a drop target. |
droppable | boolean | true | Whether cards can be dropped into (or moved to) this column. A parked
lane sets false — it still renders, but is never a target. |
lockedReason | string | — | Reason shown (and announced) for an unavailable move target — pairs with
droppable: false or business gating. |
Accessibility
- The keyboard path is not a fallback — below 768px pointer drag disables outright, so the move mode and the per-card Move menu are the only paths and are lossless by construction: every droppable column is a menu target, with visible lock reasons on unavailable ones.
- Cards form one roving tab stop per board: ↑/↓ move within a column, ←/→ across columns at a clamped index (RTL-aware), Home/End jump within the column.
- Space lifts the focused card into move mode (arrows then commit one announced step at a time; Escape ends); Enter activates the card; M opens its Move menu.
- A dragged card gains no shadow — flat by doctrine; lift reads as the
dimmed origin plus the drop-indicator line. Empty columns are real drop
targets using
Empty variant="dashed"(the classic drop-zone look). - Every move and every rejection announces through a polite live region.
| Key | Action |
|---|---|
| ↑/↓/←/→ | Browse cards (roving focus). |
| Space | Toggle move mode on the focused card. |
| Enter | Activate the focused card. |
| M | Open the focused card's Move menu. |
| Escape | End move mode. |
| Contract | States tested |
|---|---|
| Behaviour | idle, dragging, move-mode, pending, rejected, locked-target, collapsed, read-only, empty-column, drag-disabled-mobile |
| Accessibility | keyboard, labeled, status-announcement, semantic-html |
| Visual | default, dragging, drop-edge, drop-over, pending, collapsed, empty |