Kanban Board
Pro8 global-market variants: sprint board, CRM pipeline, helpdesk, compact, swimlanes, collapsible columns, rich headers and fixed-header scrolling.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvKanbanBoardComponent } from '@styloviz-pro/kanban';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Draggable with inline add
ProCRM pipeline with deal values
ProA WIP limit that actually holds
ProPersisting a move
ProWhen to use
- Visualizing work moving through stages: sprint, pipeline, helpdesk, intake.
- You want drag-and-drop between columns with optional WIP limits.
- For date-driven schedules use the Gantt Chart instead.
- The board is NOT virtualized — every card in every expanded column is in the DOM, measured at ~26 DOM nodes per card on the default variant. Around 300 rendered cards is the practical ceiling; past that, page the columns with `hasMore` + `(columnLoadMore)` rather than passing the whole backlog, which is what those inputs exist for. Virtualizing is the wrong fix here: a column is a drop target and a scroll container at once, and a recycled row cannot be the thing a finger is holding.
- Where a stage genuinely holds thousands of cards, that stage is a queue rather than a board column — show a count and a filtered Data Table for it.
Async states
- loading
- empty
- error
- retry
Item identity & owned state
- Restorable state
- collapsedColumnIds
- collapsedSwimlaneIds
- pendingCardIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Server writes
- Writes
- cardMoved
Each payload carries a
requestKey— send it with the request so a retry cannot apply the same change twice.- Rollback
- Call
event.rollback()when the save fails — the change is already on screen, and this is what puts it back. - While saving
- Bind
pendingCardIds— with two writes outstanding there is no single state to roll back to, so the action stays locked until the first one lands. - On failure
- Pass the failure to
errorto show it in place — without taking away what the user was working on.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| columns | KanbanColumn[] | [] | Column definitions with initial card data. |
| collapsedColumnIds | Set<string> (two-way) | new Set() | Which columns are collapsed (two-way). Seeded once from `col.collapsed` on the first non-empty `columns()`, after which the user owns it — a `model`, so a host restoring a board or persisting a per-user layout can both write it and see it change. It was an internal signal, which put the one piece of board state a user actually rearranges out of reach of the app that has to remember it. |
| collapsedSwimlaneIds | Set<string> (two-way) | new Set() | Which swimlane rows are collapsed (two-way). Same reasoning as collapsedColumnIds. |
| boardLabel | string | '' | What this board is, e.g. `'Sprint 24'`. Names the board's tabular summary, which is what a screen-reader user reads instead of the layout. Set it on any dashboard showing more than one board: without it both summaries are captioned identically, and a listener landing in one cannot tell which. Default: `''`. |
| variant | KanbanVariant | 'project' | Card rendering style and board layout. Default: 'project'. |
| draggable | boolean | true | Enable HTML5 drag-and-drop between columns. Default: true. |
| collapsible | boolean | false | Allow columns to be collapsed (regular variants). Default: false. |
| showAddCard | boolean | true | Show per-column "Add card" button. Default: true. |
| currency | string | '$' | Currency symbol used in CRM variant totals. Default: '$'. |
| maxCardsHeight | string | '520px' | Maximum height of the scrollable card body in the `fixed-header` variant. Accepts any valid CSS length: `'520px'`, `'60vh'`, `'calc(100vh - 200px)'`. Default: `'520px'`. |
| fillHeight | boolean | false | Fill the height the host gives the board, and scroll each column's cards inside it (project, crm, helpdesk, compact and toggle-columns variants). Give the host a definite height — `class="block h-full"` inside a flex column, say — and the columns stretch to it, their headers stay put and only the cards scroll. With an auto-height host nothing changes: the columns keep their natural height, so a phone layout can scroll the page. Default: false. |
| keyboardMove | boolean | true | Enable the keyboard drag-and-drop alternative. With a card focused, press Space to pick it up, arrow keys to move it (←/→ between columns, ↑/↓ within a column), Space/Enter to drop, and Escape to cancel. Requires `draggable`. Default: true. |
| wipLimitMode | KanbanWipLimitMode | 'soft' | What a column's `limit` does when a move would exceed it — see KanbanWipLimitMode. `'soft'` warns and allows; `'strict'` refuses the move and emits wipLimitBlocked instead of cardMoved. Default: `'soft'`. |
| loading | boolean | false | True while the board's first load is in flight. Replaces the board with a skeleton. For a *background* refresh of one column, set `loading` on that `KanbanColumn` instead — blanking a board the user is reading is worse than showing slightly stale cards. |
| error | SvDataError | string | null | null | Load failure. Replaces the board with a retry prompt. A bare string is accepted; use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse` and get sensible retry behaviour. |
| pendingCardIds | readonly string[] | ReadonlySet<string> | [] | Ids of cards with a write in flight. Those cards dim, stop responding to pointer events and cannot be dragged or picked up by keyboard — so a second move cannot race the first, and `rollback()` can never be ambiguous. |
| emptyMessage | string | '' | Message shown when the board has no columns at all. Leave empty for the default copy. |
| loadMoreLabel | string | 'Load more' | Label of the per-column "Load more" control. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| cardMoved | KanbanCardMoveEvent | Emitted after a successful drag-drop move. |
| cardClicked | KanbanCardClickEvent | Emitted when a card is clicked. |
| addCardClicked | string | Emitted when the Add-card "+" button is clicked; payload is the column ID. |
| cardAdded | KanbanCardClickEvent | Emitted after a card is created inline; payload contains the new card + column ID. |
| retry | void | The host should re-run the failed load. |
| columnLoadMore | KanbanColumnLoadMoreEvent | A column wants its next page of cards. |
| wipLimitBlocked | KanbanWipLimitEvent | A move `wipLimitMode="strict"` refused. Emitted *instead of* `cardMoved`, so the board is unchanged and there is nothing to roll back. |
| collapsedColumnIdsChange | Set<string> | Emits the new `collapsedColumnIds` whenever it changes — the output half of `[(collapsedColumnIds)]`. |
| collapsedSwimlaneIdsChange | Set<string> | Emits the new `collapsedSwimlaneIds` whenever it changes — the output half of `[(collapsedSwimlaneIds)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
ProBound to the names this component actually exposes.
Host class
ProDrive the three async inputs from your own fetch.
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `columns`, `loading` and `error`.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Native HTML5 drag-and-drop plus a built-in keyboard move mode (`keyboardMove`).
- Touch drag is its own gesture: press and hold a card for ~220ms to pick it up, drag to a column, lift to drop. Moving before it lifts is a scroll, so a column stays scrollable; holding near a board edge scrolls it sideways so a column that started off-screen is reachable. HTML5 drag-and-drop does not fire on touch at all, so without this the board could not move a card on a phone.
- `wipLimitMode` — `'soft'` (default) warns and allows; `'strict'` refuses the move before applying it and emits `(wipLimitBlocked)` instead of `(cardMoved)`. Strict holds on the drag, the arrow key AND the inline "+", because a limit that only blocks the drag is a locked front door beside an open window. A reorder inside a column is never refused, even one already over its limit — the count does not change, and freezing a full column would trap the work the limit exists to finish.
- Swimlanes, SLA tracking and deal-value bars.
- Inline card creation per column.
- `(cardMoved)` carries `requestKey` and `rollback()` — the drag is applied before the server confirms it, so the payload is also the way back.
- `pendingCardIds` marks the cards whose writes are in flight and stops them being dragged again.
- No virtualization by design — see When to use for the measured ceiling and what to do past it.
Best practices
- Do: Persist moves on `cardMoved` — it fires for both pointer drags and keyboard moves.
- Do: Bind `pendingCardIds` while a move is being written, and call `event.rollback()` when it fails. The card has already landed, and the board holds the only copy of the arrangement before the drag.
- Do: Send `event.requestKey` and ignore a key you have already applied. Two people dragging the same card is the normal case on a board, and a retried move would push it past wherever the other person put it.
- Do: Keep column counts visible so overloaded stages are obvious.
- Do: Decide what a `limit` means before you set one. `wipLimitMode` is `'soft'` by default — the badge warns and the move goes through — because a WIP limit is a team agreement first, and blocking a support agent from picking up an eighth ticket mid-incident is worse than an amber badge. Use `'strict'` only where the limit is genuinely the rule: a deploy gate, a regulated review step.
- Do: Handle `(wipLimitBlocked)` when you set `'strict'`. It fires INSTEAD of `cardMoved` — the board never changed, so there is no `rollback()` on it — and a drop that silently does nothing reads as a broken board.
- Tip: Leave `keyboardMove` on (default) so the board is operable without a mouse.
- Tip: Page long columns with `hasMore` and `(columnLoadMore)`. The board renders every card it is given, so a 340-card backlog passed in full is 340 cards of DOM.
- Don't: Do not build your own touch handling on top of the cards. Touch is already a long-press drag (about 220ms, cancelled by a scroll) that feeds the same drop indicators as the mouse, and a second gesture on the same element fights it.
Accessibility
- Keyboard at scale (X-10): each card list is ONE tab stop (the card last focused in it). Without a grabbed card ↑/↓/Home/End/PageUp/PageDown move focus within the column, ←/→ move to the neighbouring column at the same position, and typing moves to the next card whose title starts with it; a grabbed card's arrows still move the card. Documented ceiling: 2,000 cards on a board, past which a dev-mode warning fires.
- Focus after a mutation (X-12): a moved card keeps focus; if it is no longer on screen after the move, focus goes to the card now at its position in that column, then the one before it, then the column — never `<body>`.
- A card's COLUMN is its status, and a column is a position on screen — so the board publishes a table of cards with a Column cell each. Read as a flat list of cards, the one fact a board exists to show is the fact that is missing. The table gains a Swimlane column when lanes are on, and Value / Win probability or Ticket / SLA when those variants are.
- It mirrors the board rather than the data: collapsed columns and hidden lanes drop out, and a one-line overview gives the card count, how many columns are collapsed, how many cards are overdue and which columns are over their WIP limit — the at-a-glance facts the layout conveys with colour and position.
- Set `boardLabel` on any dashboard with more than one board. Without it both summaries are captioned identically and a listener landing in one cannot tell which board they are in.
- Cards are focusable `role="button"`s; Enter opens a card and, with `keyboardMove`, Space picks it up.
- Full keyboard drag alternative: Space to grab, ←/→ to change column, ↑/↓ to reorder, Space/Enter to drop, Escape to cancel — moves are announced via a live region.
- Column collapse/expand and swimlane toggles are labelled and keyboard operable (`aria-expanded`); inline add-card supports Enter/Escape.
- The WIP badge is worded, not just numeric. "5/5" read aloud is two numbers with nothing to say the second is a ceiling, and the amber that carried that meaning is invisible to a reader who cannot separate it from the grey — so the badge carries `aria-label="2 of 2 cards, at the limit"`, and "over the limit" once it is passed.
- A strict refusal is announced, on all three routes in: "Payments service v4.2 was not moved. Deploying is at its limit of 1 card." A drop that just fails to happen is indistinguishable from a board that does not work.
- An arrow-key move into a COLLAPSED column expands it. A collapsed column renders no cards, so the keystroke used to look like it had deleted the card — and the summary table mirrors what is drawn, so it stopped listing it too, leaving no way to find it again.
- Touch drag is announced on pick-up, and on a cancel or a release that never reached a column, so a gesture with no cursor and no drag image still says what it did.
Feedback on Kanban Board
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.