Board
A Kanban / column board where a drag is a field mutation, not a reorder — composing the `sortable` primitive for every drag interaction.
@garn/ui/boardOn this page
Default
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- Deps
lucide-react- Registry deps
utilsuse-controllable-statesortablecardbuttonskeleton
Import
import { Board.Column, Board.ColumnHeader, Board.Card, Board.CardHandle, Board.CardAdd, Board.Swimlane } from "@garn/ui/board";Anatomy
The parts this component is built from — compose them to assemble it.
Board.ColumnRequired[data-slot="board-column"]One column — a droppable Sortable keyed by columnId, owning its ordered value (controlled/uncontrolled), an optional WIP limit, accent tone, and collapsed/collapsible state. Routes its children into header / cards / composer regions.
Board.ColumnHeader[data-slot="board-column-header"]The column header — title + live count + accent tone, a trailing actions slot (add / overflow), and the collapse toggle. Reads the column context; must render inside a Board.Column.
Board.Card[data-slot="board-card"]A card — keyed by cardId (its identity in the column's order); disabled pins it (non-draggable). Composes card for the surface and nests a Sortable.Item for the drag/a11y wiring.
Board.CardHandle[data-slot="sortable-handle"]An optional grip that makes only itself the drag activator, so the card's own buttons/links stay clickable and semantic. Carries the sortable-handle marker so the engine detects it however deeply nested in the card's JSX — but a handle rendered inside one of your own components is invisible to that walk; declare it with hasHandle on Board.Card. Defaults to a grip icon.
Board.CardAdd[data-slot="board-card-add"]The inline composer — onAdd(text) appends, label names the collapsed trigger, placeholder the textarea. Enter commits and re-focuses (rapid add); Esc cancels.
Board.Swimlane[data-slot="board-swimlane"]A horizontal grouping band keyed by laneId, with a title and defaultCollapsed; collapsible via an aria-expanded toggle. Exported for hand-composition; the data-driven swimlanes prop composes these.
Props
The component's public props and their types.
autoScroll- Description
- Auto-scroll the nearest scroll container when a drag nears its edge.
- Type
boolean- Default
true
columns- Description
- Column definitions. Passing this switches Board to the data-driven path (it renders and manages the columns for you).
- Type
BoardColumnDef[]
defaultItems- Description
- Initial card ids per column for the uncontrolled data-driven path.
- 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- Default
false
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
- Fired with the full next `items` map after each committed move (uncontrolled data-driven path).
- 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
- Column width and card density.
- Type
BoardSize- Default
"md"
swimlanes- Description
- Swimlane definitions — horizontal bands that cross every column (data-driven path).
- Type
BoardSwimlaneDef[]
touchDelay- Description
- Long-press (ms) before a touch drag begins.
- Type
number
Plus 276 inherited native <div> attributes.
Board.Column
collapsed- Description
- Collapse the column to a rail (controlled).
- Type
boolean
collapsible- Description
- Show the collapse affordance.
- Type
boolean- Default
true
columnIdRequired- Description
- Stable column key — the group value (drag write-back is keyed on it).
- Type
string
defaultCollapsed- Description
- Start collapsed in the uncontrolled case.
- Type
boolean
defaultValue- Description
- Initial ordered card ids (uncontrolled).
- 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
- Fired when the column collapses or expands.
- Type
(collapsed: boolean) => void
onValueChange- Description
- Fired with the next ordered card ids after a reorder or cross-column move.
- Type
(value: string[]) => void
tone- Description
- Stage color carried by the column header tint.
- Type
BoardTone- Default
"neutral"
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- Default
"Add a card"
onAdd- Description
- Called with the trimmed text when a card is added.
- Type
(text: string) => void
placeholder- Description
- Placeholder for the composer input.
- Type
string- Default
"Enter a title…"
Plus 279 inherited native HTML attributes.
Board.Swimlane
defaultCollapsed- Description
- Start the lane collapsed.
- Type
boolean- Default
false
laneIdRequired- Description
- Stable lane key — pairs with a column id to key the swimlane's cells.
- Type
string
title- Description
- Lane header content; falls back to `laneId`.
- Type
React.ReactNode
Plus 279 inherited native HTML attributes.
Styling
Target these data-slots and states, 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"]States
| Selector | State | Description |
|---|---|---|
[data-slot=board-column][data-collapsed] | collapsed | A column collapsed to a vertical rail (label + count); its header toggle flips to expand (aria-expanded=false). |
[data-slot=board-column][data-over-limit] | over-limit | A column over its WIP `limit` — a danger edge + ring, and a red `N/M` count pill (soft-warn: the drop is still allowed). |
[data-slot=board-card-add][data-open] | composer-open | The inline composer is expanded (textarea + submit). |
[data-slot=board-swimlane][data-collapsed] | swimlane-collapsed | A collapsed swimlane — its cells are hidden; the toggle reads aria-expanded=false. |
[data-slot=board-card] [data-dragging] | dragging | A card being dragged — reflected on the nested Sortable.Item (elevated, follows the pointer / overlay ghost). Inherited from `sortable`. |
[data-slot=board-card] [data-placeholder] | placeholder | The source card, hidden while the overlay ghost shows instead. Inherited from `sortable`. |
| 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
When to use
A Kanban / status board where cards move between ordered columns and the move writes back a field: a sprint / project board (To do → In progress → Done), a CRM pipeline, a content workflow, an agent task board. Reach for it with Board.Column children, or drive it from data with columns + items + renderCard. Add WIP limits, swimlanes, collapse-to-rail and an inline composer as needed.
Reach for something else when
- A single reorderable list with no columns (use
sortableorlist'sreorderable). A row/column data grid or a spreadsheet (usetable). A read-only chronological feed (usetimeline). Work laid out against a horizontal time axis with dependencies (usegantt) or an hour-grid calendar (usescheduler). The raw drag mechanism with no column/card presentation (usesortabledirectly).
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).
Troubleshooting
A card's button / menu isn't clickable, or clicking it starts a drag.
Cause. A bare Board.Card is the drag activator (via the nested Sortable.Item), so interactive card content conflicts with the drag.
Fix. Add a <Board.CardHandle> to the card — only the grip starts a drag, and the rest of the card (buttons, links) stays interactive and semantic.
A card gets clipped when it's dragged out of its column.
Cause. Cross-column drags need a portal ghost; a column's overflow clips the self-translated card.
Fix. Keep a Board.Overlay in the tree. The data-driven path renders one automatically; in the composable path add <Board.Overlay>{(id) => <Card/>}</Board.Overlay> inside <Board>.
Dragging a card does nothing — it snaps back.
Cause. A drag is a mutation, but Board doesn't change your data for you — it reports the move.
Fix. In the composable path apply each column's onValueChange (use functional setState so a cross-column move doesn't stale-overwrite); data-driven, apply onItemsChange (or read onCardMove). Nothing persists until you do.
The board dev-warns about a missing accessible name, or a screen reader can't tell the columns apart.
Cause. The board region has no name, or a column has no header to label it.
Fix. Set aria-label / aria-labelledby on <Board>, and give each Board.Column a Board.ColumnHeader (its count is folded into the column region's name). Headerless swimlane cells get an aria-label instead.
Over-limit doesn't block the drop the way I expected.
Cause. WIP is soft-warn in v1 — the count pill turns red and data-over-limit fires, but the drop is still allowed.
Fix. That's intended (soft-warn). A hard block (strictWip) needs a canDrop/veto hook sortable doesn't yet expose. Enforce the block in your onCardMove if you need it now.
Swimlane cards land in the wrong cell, or items keys don't match.
Cause. The swimlaned items map is keyed per (lane, column) cell with an internal separator, not by column id.
Fix. Key each cell with Board.cellKey(laneId, columnId) (also exported as boardCellKey) — never type the separator yourself. onCardMove meta then carries fromLane/toLane too.
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.
Accessibility requirements
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.