Board
Kanban columns over the drag-reorder seam — pointer drag, keyboard move mode, a per-card actions menu, server-refusable moves, collapsed lanes.
- Status
- Since
0.4.0- Accessibility pattern
- keyboard-first drag alternative
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Board before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify boardInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/boardRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/data-table-parts, @vegastack/dropdown-menu, @vegastack/empty, @vegastack/load-more, @vegastack/skeleton, @vegastack/toast, @vegastack/tooltip, @vegastack/truncated-text, @vegastack/use-announcer, @vegastack/use-drag-reorder, @vegastack/use-media-query, @vegastack/use-mobile.
Usage
Moves recheck current card and destination permissions at drop. Asynchronous moves are serialized so a rejected move cannot roll back another pending card.
import { Board } from "@/components/ui/board";
import { BoardCard } from "@/components/ui/board-card";
<Board
aria-label="Tasks"
columns={statuses.map((s) => ({
id: s.id,
title: s.label,
items: tasksBy[s.id],
}))}
getItemId={(task) => task.id}
getItemLabel={(task) => task.title}
renderCard={(task) => (
<BoardCard
surface={false}
title={task.title}
due={task.dueAt}
priority={task.priority}
/>
)}
onMove={({ id, to }) => api.moveTask(id, to.container, to.index)}
onAdd={(status) => openCreate({ status })}
addLabel="Add task"
/>;The split is content/chrome: you render card content only — a
BoardCard with surface={false} is the standard content — and the
board owns the lanes, sticky headers, counts, the collapse strip, empty drop zones, skeletons,
paging, the add button, the lifted card and the gap it lands in. onMove is the single move
command for every input path: drag, keyboard and the card 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 (a BoardCard) — the board owns all chrome |
| Creating a card | Host — onAdd(laneId) opens the host's form with the lane pre-filled |
| Single-list reordering | SortableList |
| Lanes from a list | DataList view="board" (sections) |
| Virtualization | Deliberately none — lanes page with loadMore instead |
Examples
Task board
The full board: BoardCard content, a full-width "+ Add task" right after each lane's last card
(onAdd receives the lane's id, so the host's create form opens with that status filled in),
per-card actions with a submenu, and an empty lane ("In review") that reads "Nothing here" with
a content-width "+ Add task" as its action at rest, and "Drop here" while a card is dragged. The add button
scrolls with the cards, after the loading tail when the lane pages.
Drag a card: it lifts with a shadow and a slight tilt, the cards around it slide to make room, and it settles into its slot. Near a lane's top or bottom edge the lane scrolls; near the board's side edges the board scrolls. On a touch screen, hold a card for a quarter of a second to pick it up (the device buzzes where it can); a swipe still scrolls. Under reduced motion the card still follows the pointer, without the tilt or the slides.
Header add and trailing content
addPlacement="header" moves each lane's add button into its header, an icon button beside the
⋯ menu whose accessible name and tooltip are addLabel. A column's icon leads its title (the
status glyph of a status lane), and trailing renders after the last lane inside the sideways
scroller — an "Add lane" button. Cards sit on a slightly raised surface.
Hidden lanes. hiddenColumns lists the lanes the host has hidden under a "Hidden columns"
heading after the last lane, each with its icon and count; choosing one calls onShowColumn. A
column marked hideable offers "Hide column" in its ⋯ menu, which calls onHideColumn — the host
takes the lane out of columns and lists it in hiddenColumns. Each lane still pages on its own
(loadMore), so a task board with per-lane keysets and hidden lanes is a Board.
Full height
height="fill" is the default: the board measures where it starts and runs its lanes to the
bottom of the viewport (less fillOffset, the page's bottom padding). Cards scroll inside each
lane and the page never scrolls. Lanes are 320px (columnWidth) with a 12px gap; the board scrolls
sideways with a hidden scrollbar and fades at the edges that have more. A sideways pan — a
trackpad swipe, Shift+wheel or a touch swipe — scrolls the board wherever it starts,
over a lane or a card included; a lane body scrolls only vertically. Pass height="auto" to size lanes to their cards, or any CSS length.
Collapsed lanes
Every lane header has a collapse icon button (tooltip "Collapse lane") left of its ⋯ menu; turn it
off with collapsible={false}. The ⋯ menu holds only the lane's own actions from
getColumnActions and hides when there are none. Each lane has a subtle
top-to-bottom muted fade behind its cards, and cards keep their own surface; while a card is
dragged over a lane, the fade gives way to a solid accent wash. A collapsed lane is a slim dashed
strip with an expand icon and its count (tooltip "Expand lane"); activating it expands the lane. Control the set with collapsedColumns and
onCollapsedChange to remember it. A lane declared defaultCollapsed is a terminal lane: it
expands read-only.
Lanes keep their width (columnWidth, default 20rem) however many are collapsed: the space a
collapsed lane frees stays empty instead of stretching the others, and a board with more lanes than
fit scrolls sideways. The preview starts with two lanes collapsed.
Calm lanes
Three opt-in props keep a board with sparse lanes quiet; a board without them behaves as before.
emptyLane="compact" draws an empty lane as its header over a 48px dashed drop target, with a
+ that calls onAdd — never a tall "Nothing here" box. emptyLane="collapse" moves empty
lanes into the Hidden columns list after the last lane: choosing one brings it back (compact),
a card dragged onto it there lands in it, and a lane that a move empties stays put rather than
vanishing under the pointer. noValueLane="last" puts the lane marked noValue ("No stage") after
the others and lets it collapse even when the board is not collapsible.
A board loading its first data shows one skeleton for the whole load: with loading, lane shells
(loadingLanes, 4 by default) with skeleton titles stand in for the lanes — no skeleton cards and
no counts. A single lane still fetching its first cards shows no count until they arrive, never a
"0" over skeletons. When lanes overflow sideways, both edges fade and carry a soft scroll shadow.
Server-gated moves
Moves are optimistic: the card lands at once and shimmers while onMove's promise is pending. A
rejection snaps it back, announces it, and raises an error toast (moveErrorToast sets its
title, or false turns it off; the app mounts Toaster).
Locked lanes and auto height
A lane with droppable: false is never a target; its lockedReason shows in the lane when it is
empty. With height="auto", columnMaxHeight caps each lane's card list.
Read-only
readOnly turns every move off for the whole board and hides "+ Add" (as does a lane's own
addable: false); cards still focus, open and follow their links. A defaultCollapsed lane expands
read-only on its own.
Locked cards
canMoveItem is readOnly for one card — a view-only record on a board the user can otherwise
edit. A card it answers false for keeps the default cursor, ignores pointer, touch and
Space pick-up and is not announced as draggable, but it still focuses, opens, keeps its ⋯ actions, and other cards move around it.
Lane and card labels
Every lane and card has a plain-text name, because the board speaks them. A string title names
its lane on its own; a lane whose title is a node passes label (the board warns in
development when it has to fall back to the id). getItemLabel names the cards — in their
accessible name, their menu control ("Actions for Token audit") and every announcement.
Lane states
count is the lane's total when it has loaded only some cards ("Open, 14 tasks", through
countLabel). Above 99 the lane shows "99+" (the bell's and the Inbox's cap) while its spoken name keeps
the number; a host that counts only up to 100 says "more than 99" in countLabel. loading shows skeleton cards and marks the lane aria-busy. An empty lane shows
the design-system Empty at rest: an Inbox icon and "Nothing here" (a locked lane adds its
lockedReason). Change its copy with empty (icon, title, description, action); emptyState
replaces it outright. With onAdd, the default empty state carries "+ Add" as its action; a custom
emptyState keeps "+ Add" under it. While a card is dragged, an empty lane that accepts it shows "Drop here".
Cards as links
getItemHref makes each card a real link, rendered as <a> or as your router's link through
itemLinkRender. A click follows it and a modifier click opens a new tab; a drag never follows
it. Enter follows the link; Space still picks the card up. onCardActivate stays for cards
without an href. onCardOpen opens every card's record in place — a peek: a link card's plain click
(or Enter) calls it instead of following the link, while ⌘/Ctrl/Shift and middle clicks still open
the link. activeItemId marks the open card (data-peek-active, the accent wash)
and scrolls it into view as it changes. A card's ⋯ menu mounts the first time it is pointed at or
pressed.
Paged lanes and card actions
A lane with more cards than it has loaded takes loadMore: the next batch loads as the lane's
foot scrolls into view, with skeleton cards while it loads, through the shared
LoadMore tail — no button; a failed batch shows its error with Try
again. getItemActions(item) lists the card's own actions in
its ⋯ menu (a card with none shows no ⋯); an action with items opens a submenu. The menu has no
Move items — moves are the drag and the keyboard (Space, the arrows, Space).
On a phone
Below 768px the board shows one lane at a time at full width and snaps to each lane. Swipe to move between lanes, or tap a lane in the strip above, which shows each lane's count. The phone layout follows the viewport, not the preview frame: narrow the window or open this page on a phone to see it. To move a card to another lane, hold it, then drag it to the screen's edge: the board turns to the next lane.
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). The card moves at once; return a promise for a server-gated move — the card shimmers while it is pending, and a rejection snaps it back, announces it and raises an error toast. |
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). A BoardCard with surface={false} is the standard content. |
actionsLabel | ((label: string) => string) | (label) => `Actions for ${label}` | Accessible name of a card's ⋯ trigger when the card has actions, from its getItemLabel. |
activeItemId | string | null | — | The card whose record is open beside the board (a peek): it carries data-peek-active, takes
the accent wash, and scrolls into view (block: "nearest") when it changes. |
addLabel | string | ((column: BoardColumn<T>) => string) | "Add card" | The add button's label ("Add task"). |
addPlacement | "footer" | "header" | "footer" | Where a lane's add button sits: a full-width row after its last card, or an icon button in
the lane header beside the ⋯ menu (addLabel becomes its accessible name and tooltip). |
aria-label | string | "Board" | Accessible name for the board. |
canMoveItem | ((item: T) => boolean) | undefined (every card can move) | Whether one card can move — readOnly for a single card (a view-only record on an editable
board). A card answering false cannot be picked up by pointer, touch or Space, keeps the
default cursor and is not announced as draggable; it still opens, keeps its ⋯ actions and
stays in roving focus. Other cards still move around it. |
className | string | — | Extra classes for the board root. |
collapsedColumns | readonly string[] | — | The collapsed lanes' ids, controlled. Pair with onCollapsedChange. |
collapsible | boolean | true | Let the user collapse a lane to a slim strip from its header's ⋯ menu. |
columnMaxHeight | string | "calc(100dvh - 16rem)" | Maximum height of a lane's card list on an "auto" board, as a CSS length. |
columnWidth | string | "20rem" | Every lane's width as a CSS length. Lanes keep it whatever the board's width and however many lanes are collapsed (the board scrolls sideways when they overflow); on a phone each lane takes the full width. |
countLabel | ((n: number) => string) | (n) => n === 1 ? "1 card" : `${n} cards` | The lane's count in words, for the lane's accessible name ("Open, 14 tasks"). Name the host's noun here. |
dragDisabled | boolean | false | Turn off pointer and touch dragging (the keyboard move and the menu remain). |
emptyLane | "collapse" | "compact" | — | How a lane with no cards shows. compact: its header over a 48px drop target with "+" (when
the board has onAdd), never a tall "Nothing here" box. collapse: it leaves the board for
the Hidden columns list — choosing it there brings it back, a card dragged onto it there
lands in it, and a lane a move empties stays on the board (compact) rather than jumping
away under the pointer. Either way the compact target replaces the lane's empty and
emptyState. Unset, an empty lane shows its empty state as before. A lane with a count, a
loading lane or one that pages more is never empty. |
fillOffset | string | "1rem" | Space kept below a "fill" board, as a CSS length — the page's bottom padding. |
getColumnActions | ((column: BoardColumn<T>) => RowAction[]) | — | A lane's own actions, listed in its header's ⋯ menu; the ⋯ hides when a lane has none. Collapse is its own icon button left of the ⋯. |
getItemActions | ((item: T) => RowAction[]) | — | A card's own actions (open, edit, archive), listed in its ⋯ menu — one menu per card. A card with no actions shows no ⋯. |
getItemHref | ((item: T) => string | undefined) | — | Make a card a real link. A card with an href renders as an <a> (or
itemLinkRender): a click, Enter, and every modifier click are the
browser's own, and onCardActivate is not called for it. Space still
picks it up. |
getItemLabel | ((item: T) => string) | — | Plain-text card name — used in the card's accessible name, its menu control ("Move Write spec") and in move announcements. Without it the card is named by its content, the control is "Move card" and announcements say "card". |
height | "auto" | "fill" | (string & {}) | "fill", or "auto" when `columnMaxHeight` is set | The board's height: "fill" runs the lanes from where the board starts to the bottom of the
viewport (cards scroll inside each lane, the page does not scroll); "auto" sizes lanes to
their cards up to columnMaxHeight; any other value is a CSS length. |
hiddenColumns | readonly BoardHiddenColumn[] | — | Lanes the host has hidden, listed under a "Hidden columns" heading after the last lane (and
before trailing); choosing one calls onShowColumn. |
hiddenColumnsLabel | string | "Hidden columns" | The hidden-lanes list's heading. |
hideColumnLabel | string | "Hide column" | The lane menu's hide item. |
itemLinkRender | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | <a /> | The element a card link renders — a router link such as <Link />. It
receives the href and the board's props. |
loading | boolean | false | The board is loading its first data: lane shells with skeleton titles stand in for the
lanes, with no cards and no counts — one skeleton for the whole load, never a skeleton card
under a lane that says "0". columns is ignored meanwhile. |
loadingLanes | number | 4 | How many lane shells loading shows — the number of lanes the board will have. |
moveErrorToast | false | ((label: string, error: unknown) => React.ReactNode) | (label) => `Couldn't move ${label}` | Raise an error toast when onMove rejects. A function builds the toast's title from the
card's label; false leaves the rejection to the announcement alone. |
noValueLane | "last" | — | last puts the lane marked noValue after every other lane, and lets it collapse even on a
board that is not collapsible. Unset, lanes keep the order given. |
onAdd | ((columnId: string) => void) | — | Show "+ Add" after each lane's last card (or as an empty lane's action) and call this with the lane's id — the host opens its create form with the lane's status filled in. |
onCardActivate | ((item: T, event: React.SyntheticEvent) => void) | — | Activate a card (open its record). Cards without an href are real buttons; a card with one is a link and never calls this. |
onCardOpen | ((item: T, event: React.SyntheticEvent) => void) | — | Open a card's record in place — a peek beside the board — from EVERY card. A card without an
href is a button that calls it; a card with one stays a real link, and a plain click (or
Enter) calls this instead of following it, while ⌘/Ctrl/Shift and middle clicks still open the
link where the browser would. Wins over onCardActivate. |
onCollapsedChange | ((ids: string[]) => void) | — | Called with the collapsed lanes' ids when the user collapses or expands one. |
onHideColumn | ((id: string) => void) | — | Hide a lane — from "Hide column" in the ⋯ menu of a lane marked hideable. The host takes it
out of columns and lists it in hiddenColumns. |
onShowColumn | ((id: string) => void) | — | Show a hidden lane again — the host puts it back in columns. |
readOnly | boolean | false | Turn off every move — drag and keyboard. Cards still open and keep their own actions. |
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 lane header, before its ⋯ menu. |
trailing | React.ReactNode | — | Content after the last lane, inside the sideways scroller — a list of hidden lanes, an "Add lane" button. |
Data attributes and CSS variables on Board
| Attribute | Values |
|---|---|
data-active | "" |
data-auto-hidden | "" |
data-board-card-id | mirrors a prop or state value |
data-board-card-menu | "" |
data-board-lane | mirrors a prop or state value |
data-board-strip | "" |
data-column | mirrors a prop or state value |
data-compact | "" |
data-drag-pending | "" |
data-drag-surface | "" |
data-dragging | "" |
data-drop-over | "" |
data-lane | mirrors a prop or state value |
data-lifted | "" |
data-move-locked | "" |
data-no-value | "" |
data-peek-active | "" |
data-read-only | "" |
data-settling | "" |
data-slot | "board" | "board-card" | "board-card-body" | "board-card-menu" | "board-card-placeholder" | "board-card-surface" | "board-column" | "board-column-add" | "board-column-body" | "board-column-collapse" | "board-column-collapsed" | "board-column-collapsed-title" | "board-column-count" | "board-column-empty" | "board-column-expand-icon" | "board-column-header" | "board-column-icon" | "board-column-menu" | "board-column-skeleton" | "board-column-skeleton-lane" | "board-column-title" | "board-drag-overlay" | "board-fade-end" | "board-fade-start" | "board-hidden-column" | "board-hidden-columns" | "board-lane-tabs" | "board-scroller" | "board-trailing" |
data-tabbed | "" |
data-visible | "" |
--board-column-max-height | CSS custom property |
--board-column-width | CSS custom property |
--board-gap-height | CSS custom property |
--board-height | CSS custom property |
--drag-w | CSS custom property |
--drag-x | CSS custom property |
--drag-y | 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) — the loaded cards of a paged lane. |
title* | React.ReactNode | — | Column heading content. |
addable | boolean | true | Show the "+ Add" button after this lane's last card (when the board has onAdd). |
collapsed | boolean | false | Deprecated.Alias of defaultCollapsed, kept for existing boards. |
count | number | items.length | The lane's total, when it holds more than it has loaded. Shown as the lane's muted count and spoken in its name ("Open, 14 tasks"). |
defaultCollapsed | boolean | false | Start 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. |
empty | BoardEmpty | — | An empty lane's copy: the design-system Empty it shows at rest (an Inbox icon and
"Nothing here" by default). Pass only what this lane needs to say differently. |
emptyState | React.ReactNode | — | Replace the empty lane's Empty with your own node. Prefer empty, which keeps the
standard look. While a card is being dragged an empty droppable lane always shows the
"Drop here" zone. |
hideable | boolean | false | Offer "Hide column" in the lane's ⋯ menu (when the board has onHideColumn): a lane that is
rarely worked in (Cancelled, Duplicate) leaves the board for its Hidden columns list. |
icon | React.ReactNode | — | An icon before the lane's title — the status glyph of a status lane. |
label | string | the `title` when it is a string | Plain-text lane name — used in the lane's accessible name and every
announcement. Required when title is not a string (a
Badge, an icon + text): the board cannot read a name out of a node, and
warns in development when it has to fall back to the column id. |
loading | boolean | false | The lane is fetching: skeleton cards show below any loaded cards (in
place of the empty state) and the lane is aria-busy. |
loadMore | Omit<LoadMoreProps, "className" | "ref"> | — | Keyset paging for this lane. The lane loads its next batch as its foot scrolls into view and shows skeleton cards while it loads; a failed batch shows its error with Try again. |
lockedReason | string | — | Reason shown (and announced) for an unavailable move target — pairs with
droppable: false or business gating. |
noValue | boolean | false | The lane for cards with no value in the grouping field ("No status"). The board's
noValueLane places it. |
Accessibility
- Every move has a keyboard path. Space picks the focused card up, the arrows move it (↑/↓ within its lane, ←/→ across lanes, RTL-aware), Space drops it and Escape puts it back. Nothing is committed until the drop, and each step is announced ("In review, position 2 of 3").
- A card locked by
canMoveItem(or in a read-only lane) has no "Draggable card" role description, and Space does not pick it up; it still opens and keeps its menu. - M opens the card's ⋯ menu of its own actions (it lists no Move items).
- Cards form one roving tab stop per board: ↑/↓ within a lane, ←/→ across, Home/End within the lane.
- Each lane is a region named by its label and total ("Open, 14 tasks"); a loading lane is
aria-busy. The card's activator (a button, or a link) is a sibling of its content, so aBoardCard's done tick is its own control, never nested inside another. - Moves, drops, cancels and rejections announce through one polite live region.
- No focus ring: the focused card takes the system's background tint (FOC-13).
| Key | Action |
|---|---|
| ↑/↓/←/→ | Browse cards; while picked up, move the card. |
| Space | Pick the focused card up; drop it. |
| Escape | Cancel the move and put the card back. |
| Enter | Activate the focused card, or follow its link. |
| M | Open the focused card's menu. |
| Contract | States tested |
|---|---|
| Behaviour | idle, dragging, move-mode, pending, rejected, locked-target, collapsed, read-only, empty-column, loading, link-card, optimistic, auto-load, add, phone-single-lane |
| Accessibility | keyboard, labeled, status-announcement, semantic-html |
| Visual | default, lifted, drop-gap, drop-over, pending, collapsed, empty, drop-here, loading, edge-fade |