Board
A Kanban / column board — records grouped into ordered columns where a drag is a field mutation (onCardMove). Composes the `sortable` primitive for all drag (pointer, keyboard, touch, cross-column, overlay ghost, announcements) and adds column headers with a live count + accent tone, WIP limits, collapse-to-rail, swimlanes, an inline card composer, and empty/loading states. Data-driven or composable, controlled or uncontrolled.
@garn/ui/boardDefault
The composable path — `Board.Column` children each own their ordered `value`; cross-column moves fire both columns' onValueChange plus one onCardMove, with a hand-placed Board.Overlay.
Overview
Composable
The composable path — `Board.Column` children each own their ordered `value`; cross-column moves fire both columns' onValueChange plus one onCardMove, with a hand-placed Board.Overlay.
Data Driven
The data-driven path — `columns` + `items` + `renderCard`; the board maps the model onto the parts and applies each move atomically. `onCardAdd` turns on the inline composer.
Columns
Wip Limit
WIP limits — a column soft-warns (count amber → red, a danger ring) at and over its `limit`; 'In progress' starts over a limit of 2.
Collapsible
Collapse a column to a rail — the header rotates, keeps its count, and flips its toggle to expand ('Done' starts collapsed).
Cards
Card Handle
A rich card with an explicit grip (`Board.CardHandle`) — only the grip drags, so the card's own menu button stays clickable and semantic.
Grouping
Swimlanes
Swimlanes — horizontal bands crossing every column; cards move across columns AND lanes on one machine, cells keyed by Board.cellKey. Shared column headers sit once on top.
States
Loading
The loading state — skeleton columns while cards stream in.
Empty
An empty column still renders a labelled 'No cards' drop-zone.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add board- Registry
board- Source
- board.tsx
- Deps
lucide-react- Registry deps
utilsuse-controllable-statesortablecardbuttonskeleton
Import
import { Board } from "@garn/ui/board";Props
Generated from the component's TypeScript types.
autoScroll- Description
- Auto-scroll the nearest scroll container near an edge (default true).
- Type
boolean
columns- Description
- No description.
- Type
BoardColumnDef[]
defaultItems- Description
- No description.
- Type
Record<string, string[]>
getCardLabel- Description
- Map a card id to the label screen readers announce during a drag.
- Type
(cardId: string) => string
items- Description
- Ordered card ids per column (or per `lanecolumn` cell when swimlaned).
- Type
Record<string, string[]>
loading- Description
- Show skeleton columns instead of content.
- Type
boolean
onCardAdd- Description
- Enable an inline composer per column; receives the target column (+ lane).
- Type
(columnId: string, text: string, laneId?: string) => void
onCardMove- Description
- Fired once per committed move — the write-back contract (drag = mutation).
- Type
(cardId: string, meta: BoardCardMoveMeta) => void
onItemsChange- Description
- No description.
- Type
(items: Record<string, string[]>) => void
renderCard- Description
- Render one card's content by id (data-driven).
- Type
(cardId: string, ctx: { columnId: string; laneId?: string; }) => React.ReactNode
size- Description
- No description.
- Type
BoardSize
swimlanes- Description
- No description.
- Type
BoardSwimlaneDef[]
touchDelay- Description
- Long-press (ms) before a touch drag begins.
- Type
number
Plus 276 inherited native <div> attributes.
Board.Column
collapsed- Description
- No description.
- Type
boolean
collapsible- Description
- Show the collapse affordance (default true).
- Type
boolean
columnIdRequired- Description
- Stable column key — the group value (drag write-back is keyed on it).
- Type
string
defaultCollapsed- Description
- No description.
- Type
boolean
defaultValue- Description
- No description.
- Type
string[]
laneId- Description
- Internal — set by the swimlane renderer so the container id folds (lane, column).
- Type
string
limit- Description
- WIP limit — soft-warns at/over it.
- Type
number
onCollapsedChange- Description
- No description.
- Type
(collapsed: boolean) => void
onValueChange- Description
- No description.
- Type
(value: string[]) => void
tone- Description
- No description.
- Type
BoardTone
value- Description
- Ordered card ids (controlled).
- Type
string[]
Plus 278 inherited native HTML attributes.
Board.ColumnHeader
actions- Description
- Trailing slot — an add button, overflow menu, etc.
- Type
React.ReactNode
Plus 280 inherited native HTML attributes.
Board.Card
cardIdRequired- Description
- The card's stable key — its identity in the column's ordered value.
- Type
string
disabled- Description
- Make this card non-draggable (a pinned card).
- Type
boolean
hasHandle- Description
- Declare that a `Board.CardHandle` lives somewhere in this card's rendered output. Handle auto-detection only walks the JSX passed as children — a handle rendered inside one of your own components is invisible to it, which would silently make the whole card the drag activator.
- Type
boolean
Plus 280 inherited native HTML attributes.
Board.CardAdd
label- Description
- The collapsed trigger label.
- Type
string
onAdd- Description
- Called with the trimmed text when a card is added.
- Type
(text: string) => void
placeholder- Description
- No description.
- Type
string
Plus 279 inherited native HTML attributes.
Board.Swimlane
defaultCollapsed- Description
- No description.
- Type
boolean
laneIdRequired- Description
- No description.
- Type
string
title- Description
- No description.
- Type
React.ReactNode
Plus 279 inherited native HTML attributes.
Styling
Target these data-slots and remap these tokens to restyle without forking the component.
[data-slot="board"][data-slot="board-column"][data-slot="board-column-header"][data-slot="board-count"][data-slot="board-card"][data-slot="board-empty"][data-slot="board-card-add"][data-slot="board-swimlane"][data-slot="sortable-overlay"]| Property | Token | Tier |
|---|---|---|
columnWidth | --garn-board-col-w | component · identity |
columnWidthSm | --garn-board-col-w-sm | component · identity |
railWidth | --garn-board-rail-w | component · identity |
headerTint | --garn-badge-brand-bg | semantic |
overLimitEdge | --garn-badge-danger-edge | semantic |
surface | --garn-muted | semantic |
hairline | --garn-border | semantic |
Private instance vars (never externalize): --garn-board-col
Overview
Board is a Kanban / column board — records grouped into ordered columns (a status/stage field), where a drag is a mutation: move a card and it fires onCardMove(id, { fromColumn, toColumn, index }). It is the one renderer where reordering means changing data, not just order. Every drag behaviour — pointer, keyboard (Space lifts · arrows move across columns · Esc cancels), touch long-press, edge auto-scroll, the portal overlay ghost and the screen-reader announcements — is delegated to the sortable primitive (a Sortable.Group hoists one drag machine across the per-column Sortables). On top of that engine Board adds what makes it a board: column headers with a live count + accent tone, WIP limits with an over-limit warning, collapse-to-rail, swimlanes, an inline card composer, and empty/loading states — with the structural a11y (a labelled region whose columns are labelled regions with the count in their name) sitting over sortable's drag a11y. Author it composable (Board.Column children, each owning its value) or data-driven (columns + items + renderCard), controlled or uncontrolled. It is the named CollectionView .Board renderer.
Guidelines
Pick a path: composable or data-driven. For a fixed, hand-laid board use Board.Column children — each column owns its ordered value/onValueChange (exactly the sortable recipe it packages). For a model-driven board (or the CollectionView adapter), pass columns + items + renderCard; Board maps the model onto the same parts and applies each move atomically in one onDragEnd.
A drag is a mutation — own the write-back. onCardMove (and, data-driven, onItemsChange) hand you the committed move; apply it to your source of truth. Nothing persists until you do.
Reach for the built-ins. limit for WIP discipline (soft-warn: the count turns amber → red, a danger ring, and the status is announced); collapsed/collapsible for collapse-to-rail; swimlanes (keyed per Board.cellKey(lane, col)) for a second grouping axis; Board.CardAdd / onCardAdd for the inline composer. Style only through tone on the column and the card surface; size via size, never hand-padding.
Keep it operable. Give the board an accessible name, add a Board.CardHandle when a card has its own buttons/links, and always render a Board.Overlay (the data-driven path does this for you) so the dragged card escapes a column's overflow. Don't strip the focus rings or silence the announcements.
Best practices
- Give the board an accessible name (
aria-label/aria-labelledby) — it's a required contract. - Own the write-back: apply
onCardMove(and data-drivenonItemsChange) to your state; a drag doesn't persist until you do. - Pick one authoring path — composable
Board.Columnchildren (each owns itsvalue) or data-drivencolumns/items/renderCard. - Add a
Board.CardHandlewhen a card has its own buttons/links, so only the grip drags. - Use
limitfor WIP discipline,swimlanes(keyed perBoard.cellKey) for a second axis, andBoard.CardAddfor low-friction capture. - Keep a
Board.Overlayin the tree (the data-driven path renders one) so the dragged card isn't clipped by a column's overflow.
- Don't reimplement drag on top of Board — pointer, keyboard, touch, auto-scroll, the overlay and announcements all come from
sortable. - Don't make a card with interactive content the bare drag activator — nest a
Board.CardHandle, or its clicks start a drag. - Don't hardcode a column colour or a card padding — use
toneon the column andsize; geometry rides--garn-board-*tokens. - Don't expect a drag to persist by itself — Board reports the move; you apply it (drag = mutation, but you own the data).
- Don't type the swimlane cell key by hand — compose it with
Board.cellKey(laneId, columnId)(it folds an internal separator).
Accessibility
- Role
group- ARIA APG
- Drag-and-Drop (keyboard-operable reorder)
- Focus
- Focus follows the card through a keyboard drag (via sortable's roving activators); focus-visible rings are preserved on cards, handles and collapse toggles. Don't strip them.
Enforceable contracts
The board root has an accessible name via `aria-label` or `aria-labelledby` (a dev warning fires otherwise).
when always
Each column region's accessible name includes its live card count (and WIP status when a `limit` is set), spoken via an sr-only phrase — the visible count pill stays aria-hidden.
when a column is rendered
Add a `Board.CardHandle` so only the grip drags and the card content stays clickable; a bare card is the drag activator and would swallow the clicks.
when a card carries interactive content (buttons, links, inputs)
The collapse control is a button with aria-expanded and a label naming the column/lane.
when a collapsible column or swimlane is rendered
Keyboard
| Tab | Move into the cards; one card is tabbable per column (sortable's roving activators). |
| SpaceEnter | On a card (or its Board.CardHandle): pick it up; press again to drop it at the chosen position. |
| ArrowUpArrowDown | Move the lifted card within its column. |
| ArrowLeftArrowRight | Move the lifted card to the previous / next column (RTL-aware). |
| HomeEnd | Send the lifted card to the first / last position in its column. |
| Escape | Cancel the drag and restore the card to its original column and position. |
- • The board is a role=group region that REQUIRES an accessible name (`aria-label` / `aria-labelledby`); a dev-only warning fires if neither is set.
- • Each column is a labelled role=group whose accessible name carries the live count and WIP status — e.g. "To do, 3 cards", "In progress, 3 of 2 cards, over the limit" — composed from the header title + an sr-only phrase (the visible count pill is aria-hidden).
- • Cards inherit sortable's drag a11y: the activator is a real button with an accessible name, aria-roledescription and an aria-describedby pointing at the keyboard instructions; Space/Enter lifts, arrows move (cross-axis hops between columns), Esc cancels; a polite live region announces pick-up / move / drop / cancel. `getCardLabel` names each card for those announcements.
- • Collapse toggles (column and swimlane) are real buttons with aria-expanded and a name ("Collapse/Expand {title}"); a collapsed column's header becomes the rail and keeps the count + expand button.
- • A `Board.CardHandle` makes only the grip the drag activator, so the card body's own buttons/links stay clickable and semantic — required when a card carries interactive content.
- • The empty-column drop-zone is aria-hidden (the labelled column region already announces "0 cards"); swimlane bands are labelled role=group rows.
- Status
- Beta
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.