Skip to content
Component installs need the registry setup
VegaStack Design

Board

Kanban columns over the drag-reorder seam — pointer drag, keyboard move mode, a per-card actions menu, server-refusable moves, collapsed lanes.

Status
stable
Since
0.4.0
Accessibility pattern
keyboard-first drag alternative

Last updated

Qualified
Acme renewal
$12,400
PSPriya Shah
Globex expansion
$48,000
MKManoj Kumar
Proposal
Initech pilot
$9,800
ALAlex Lee
Won
Nothing here

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 board

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/board

Run 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

BehaviourWhere it lives
Ordering + persistenceHost — onMove is a request; reject its promise to refuse a move
Card contentHost, via renderCard (a BoardCard) — the board owns all chrome
Creating a cardHost — onAdd(laneId) opens the host's form with the lane pre-filled
Single-list reorderingSortableList
Lanes from a listDataList view="board" (sections)
VirtualizationDeliberately 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.

To do
Send the revised lighting schedule
Harbour Tower · Acme Build
PSPriya ShahOverdue 2dUrgent
Confirm the fixture count for level 3
Harbour Tower · Acme Build
MKManoj KumarDue todayHigh
Book the site visit
Riverside · Northwind
ALAlex LeeDue in 5d
In progress
Draft the quote for the lobby pendants
Riverside · Northwind
PSPriya ShahDue tomorrowMedium
In review
Nothing here
Done
Share the photometric report
Harbour Tower · Acme Build
ALAlex LeeOverdue 4d

 

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.

Todo
↳ Harbour Tower handover
Send the revised lighting schedule
PS
Overdue
Created 9 Oct
Confirm the fixture count for level 3
MK
In 0d
Created 9 Oct
In Progress
Draft the quote for the lobby pendants
PS
In 1d
Created 9 Oct
In Review
Nothing here

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.

To do
Send the revised lighting schedule
Harbour Tower · Acme Build
PSPriya ShahOverdue 2dUrgent
Confirm the fixture count for level 3
Harbour Tower · Acme Build
MKManoj KumarDue todayHigh
Book the site visit
Riverside · Northwind
ALAlex LeeDue in 5d
In progress
Draft the quote for the lobby pendants
Riverside · Northwind
PSPriya ShahDue tomorrowMedium
In review
Nothing here
Done
Share the photometric report
Harbour Tower · Acme Build
ALAlex LeeOverdue 4d

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.

To do
Send the revised lighting schedule
Harbour Tower · Acme Build
PSPriya ShahOverdue 2dUrgent
Confirm the fixture count for level 3
Harbour Tower · Acme Build
MKManoj KumarDue todayHigh
Book the site visit
Riverside · Northwind
ALAlex LeeDue in 5d
In progress
Draft the quote for the lobby pendants
Riverside · Northwind
PSPriya ShahDue tomorrowMedium

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.

Qualified
Acme renewal
$12,400
PSPriya Shah
Globex expansion
$48,000
MKManoj Kumar
Proposal
Initech pilot
$9,800
ALAlex Lee
Won
Parked
No stage
Qualified
Acme renewal
$12,400
PSPriya Shah
Globex expansion
$48,000
MKManoj Kumar
Proposal
Initech pilot
$9,800
ALAlex Lee

Hidden columns

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).

Open
Northwind upsell
$22,000
MKManoj Kumar
In review
Nothing here

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.

Active
Acme renewal
$12,400
PSPriya Shah
Globex expansion
$48,000
MKManoj Kumar
Initech pilot
$9,800
ALAlex Lee
Umbrella retainer
$3,100
PSPriya Shah
On hold
Nothing here
Archive
Soylent lapse
$1,200
ALAlex Lee

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.

To do
Send the revised lighting schedule
Harbour Tower · Acme Build
PSPriya ShahOverdue 2dUrgent
Confirm the fixture count for level 3
Harbour Tower · Acme Build
MKManoj KumarDue todayHigh
Book the site visit
Riverside · Northwind
ALAlex LeeDue in 5d
In progress
Draft the quote for the lobby pendants
Riverside · Northwind
PSPriya ShahDue tomorrowMedium
In review
Nothing here
Done
Share the photometric report
Harbour Tower · Acme Build
ALAlex LeeOverdue 4d

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.

Qualified
Acme renewal
$12,400
PSPriya Shah
Globex expansion
$48,000 · View only
Proposal
Initech pilot
$9,800
ALAlex Lee
Won
Nothing here

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.

To do
Token audit
2d
PSPriya Shah
Menu copy pass
1d
MKManoj Kumar
Blocked
Invoice export
3d
ALAlex Lee
Done
Nothing here

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".

Open
Token audit
2d
PSPriya Shah
Menu copy pass
1d
MKManoj Kumar
In review
Blocked
Nothing blocked
Move a task here when it waits on someone

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.

To do
Token audit
2d
PSPriya Shah
Menu copy pass
1d
MKManoj Kumar
Doing
Invoice export
3d
ALAlex Lee

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).

Qualified
Acme renewal
$12,400
PSPriya Shah
Proposal
Initech pilot
$9,800
ALAlex Lee

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.

Qualified
Acme renewal
$12,400
PSPriya Shah
Globex expansion
$48,000
MKManoj Kumar
Proposal
Initech pilot
$9,800
ALAlex Lee
Won
Nothing here

API Reference

PropTypeDefaultDescription
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.
activeItemIdstring | 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.
addLabelstring | ((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-labelstring"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.
classNamestring—Extra classes for the board root.
collapsedColumnsreadonly string[]—The collapsed lanes' ids, controlled. Pair with onCollapsedChange.
collapsiblebooleantrueLet the user collapse a lane to a slim strip from its header's ⋯ menu.
columnMaxHeightstring"calc(100dvh - 16rem)"Maximum height of a lane's card list on an "auto" board, as a CSS length.
columnWidthstring"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.
dragDisabledbooleanfalseTurn 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.
fillOffsetstring"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 setThe 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.
hiddenColumnsreadonly BoardHiddenColumn[]—Lanes the host has hidden, listed under a "Hidden columns" heading after the last lane (and before trailing); choosing one calls onShowColumn.
hiddenColumnsLabelstring"Hidden columns"The hidden-lanes list's heading.
hideColumnLabelstring"Hide column"The lane menu's hide item.
itemLinkRenderReact.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.
loadingbooleanfalseThe 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.
loadingLanesnumber4How many lane shells loading shows — the number of lanes the board will have.
moveErrorToastfalse | ((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.
readOnlybooleanfalseTurn off every move — drag and keyboard. Cards still open and keep their own actions.
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 lane header, before its ⋯ menu.
trailingReact.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

AttributeValues
data-active""
data-auto-hidden""
data-board-card-idmirrors a prop or state value
data-board-card-menu""
data-board-lanemirrors a prop or state value
data-board-strip""
data-columnmirrors a prop or state value
data-compact""
data-drag-pending""
data-drag-surface""
data-dragging""
data-drop-over""
data-lanemirrors 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-heightCSS custom property
--board-column-widthCSS custom property
--board-gap-heightCSS custom property
--board-heightCSS custom property
--drag-wCSS custom property
--drag-xCSS custom property
--drag-yCSS custom property

Column

PropTypeDefaultDescription
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.
addablebooleantrueShow the "+ Add" button after this lane's last card (when the board has onAdd).
collapsedbooleanfalseDeprecated.Alias of defaultCollapsed, kept for existing boards.
countnumberitems.lengthThe 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").
defaultCollapsedbooleanfalseStart 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.
emptyBoardEmpty—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.
emptyStateReact.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.
hideablebooleanfalseOffer "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.
iconReact.ReactNode—An icon before the lane's title — the status glyph of a status lane.
labelstringthe `title` when it is a stringPlain-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.
loadingbooleanfalseThe lane is fetching: skeleton cards show below any loaded cards (in place of the empty state) and the lane is aria-busy.
loadMoreOmit<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.
lockedReasonstring—Reason shown (and announced) for an unavailable move target — pairs with droppable: false or business gating.
noValuebooleanfalseThe 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 a BoardCard'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).
KeyAction
↑/↓/←/→Browse cards; while picked up, move the card.
SpacePick the focused card up; drop it.
EscapeCancel the move and put the card back.
EnterActivate the focused card, or follow its link.
MOpen the focused card's menu.
ContractStates tested
Behaviouridle, dragging, move-mode, pending, rejected, locked-target, collapsed, read-only, empty-column, loading, link-card, optimistic, auto-load, add, phone-single-lane
Accessibilitykeyboard, labeled, status-announcement, semantic-html
Visualdefault, lifted, drop-gap, drop-over, pending, collapsed, empty, drop-here, loading, edge-fade

Do / Don't

Do
Keep onMove the single move command and refuse a bad move by rejecting its promise — the card snaps back, the rejection is announced and a toast says so.
Don't
Re-sort the lane yourself while the promise is pending — the board already shows the move optimistically.
Do
Render a BoardCard with surface={false} and put the card's actions in getItemActions.
Don't
Pass actions to the BoardCard as well — the card would show two ⋯ menus.

On this page