Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

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
stable
Since
0.4.0
Accessibility pattern
keyboard-first drag alternative

Last updated

Qualified2
Proposal1
Won0

Install

Add Board from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/board

The 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

BehaviourWhere it lives
Ordering + persistenceHost — onMove is a request; reject its promise to refuse a move
Card contentHost, via renderCard — the board owns all chrome
Single-list reorderingSortableList
Column model / stagesHost data — columns are props, never fetched
VirtualizationDeliberately 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.

Open1
In review0

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.

Active4
On hold0
Archive1

API Reference

PropTypeDefaultDescription
columns*readonly BoardColumn<T>[]Columns in display order.
getItemId*(item: T) => stringStable 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.ReactNodeRender a card's CONTENT only — the board owns the card chrome (surface, border, focus, drag affordances).
aria-labelstring"Board"Accessible name for the board.
classNamestringExtra classes for the board root.
columnMaxHeightstring"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.
columnWidthstring"18rem"Column width as a CSS length, applied through the --board-column-width custom property.
dragDisabledbooleanfalseForce-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.
refReact.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

AttributeValues
data-columnmirrors a prop or state value
data-drag-itemmirrors a prop or state value
data-drag-pendingmirrors a prop or state value
data-draggingmirrors a prop or state value
data-drop-edgemirrors a prop or state value
data-drop-overmirrors 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-heightCSS custom property
--board-column-widthCSS custom property

Column

PropTypeDefaultDescription
id*stringStable column id — the container identity moves target.
items*readonly T[]Cards in display order (controlled).
title*React.ReactNodeColumn heading content.
collapsedbooleanfalseRender 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.
droppablebooleantrueWhether cards can be dropped into (or moved to) this column. A parked lane sets false — it still renders, but is never a target.
lockedReasonstringReason 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.
KeyAction
///Browse cards (roving focus).
SpaceToggle move mode on the focused card.
EnterActivate the focused card.
MOpen the focused card's Move menu.
EscapeEnd move mode.
ContractStates tested
Behaviouridle, dragging, move-mode, pending, rejected, locked-target, collapsed, read-only, empty-column, drag-disabled-mobile
Accessibilitykeyboard, labeled, status-announcement, semantic-html
Visualdefault, dragging, drop-edge, drop-over, pending, collapsed, empty

Do / Don't

Do
Keep onMove the single move command and refuse bad moves by rejecting its promise — every input path gets the shimmer and announced snap-back.
Don't
Give a dragged card a drop shadow to 'lift' it — only true overlays get shadow-overlay; separation is the surface ladder and the one border.

On this page