Skip to content
ShineUI
Install

Board

Cards in columns, moved three ways.

<sui-board>DataStable

About

Kanban board. Columns come from columns, cards from cards, grouped by the field groupBy names. A card moves three ways, pointer drag with an insertion line, a keyboard lift with every step announced, and the move() method, and all three raise one cancelable suiMove. Reach for it wherever a team stands in front of its work by stage.

Usage

<sui-board aria-label="Launch board" group-by="status"></sui-board> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-board"); el.cards = [ { id: "WS-105", title: "Draft the onboarding email", status: "backlog", meta: "Due 26 Sep", owner: "Mira Okafor" }, { id: "WS-107", title: "Add the keyboard shortcuts page", status: "backlog", owner: "Teo Lindqvist" }, { id: "WS-101", title: "Write the release notes", status: "ready", meta: "Due 14 Sep", owner: "Mira Okafor" }, { id: "WS-108", title: "Rename the workspace settings", status: "ready", subtitle: "Design", owner: "Ana Ruiz" }, { id: "WS-102", title: "Rotate the signing keys", status: "doing", subtitle: "Ops", pill: { label: "Blocked", severity: "attention" }, owner: "Teo Lindqvist", }, { id: "WS-103", title: "Migrate the billing webhooks", status: "doing", meta: "Due 12 Sep", owner: "Ana Ruiz" }, { id: "WS-104", title: "Fix the export timeout", status: "review", meta: "Due 12 Sep", owner: "Mira Okafor" }, { id: "WS-106", title: "Ship the audit log", status: "done", pill: { label: "Shipped", severity: "positive" }, owner: "Ana Ruiz" }, ]; /* SuiBoardCard[] */ el.columns = [ { id: "backlog", heading: "Backlog" }, { id: "ready", heading: "Ready" }, { id: "doing", heading: "In progress" }, { id: "review", heading: "Review" }, { id: "done", heading: "Done" }, ]; /* SuiBoardColumn[] */ el.addEventListener("suiCardClick", (e) => console.log(e.detail)); </script>
import { SuiBoard } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const cards = [ { id: "WS-105", title: "Draft the onboarding email", status: "backlog", meta: "Due 26 Sep", owner: "Mira Okafor" }, { id: "WS-107", title: "Add the keyboard shortcuts page", status: "backlog", owner: "Teo Lindqvist" }, { id: "WS-101", title: "Write the release notes", status: "ready", meta: "Due 14 Sep", owner: "Mira Okafor" }, { id: "WS-108", title: "Rename the workspace settings", status: "ready", subtitle: "Design", owner: "Ana Ruiz" }, { id: "WS-102", title: "Rotate the signing keys", status: "doing", subtitle: "Ops", pill: { label: "Blocked", severity: "attention" }, owner: "Teo Lindqvist", }, { id: "WS-103", title: "Migrate the billing webhooks", status: "doing", meta: "Due 12 Sep", owner: "Ana Ruiz" }, { id: "WS-104", title: "Fix the export timeout", status: "review", meta: "Due 12 Sep", owner: "Mira Okafor" }, { id: "WS-106", title: "Ship the audit log", status: "done", pill: { label: "Shipped", severity: "positive" }, owner: "Ana Ruiz" }, ]; /* SuiBoardCard[] */ const columns = [ { id: "backlog", heading: "Backlog" }, { id: "ready", heading: "Ready" }, { id: "doing", heading: "In progress" }, { id: "review", heading: "Review" }, { id: "done", heading: "Done" }, ]; /* SuiBoardColumn[] */ return <SuiBoard aria-label="Launch board" groupBy="status" cards={cards} columns={columns} onSuiCardClick={(e) => console.log(e.detail)} />; }
<script setup> import { SuiBoard } from "@shineui/vue"; import "@shineui/core/shineui.css"; const cards = [ { id: "WS-105", title: "Draft the onboarding email", status: "backlog", meta: "Due 26 Sep", owner: "Mira Okafor" }, { id: "WS-107", title: "Add the keyboard shortcuts page", status: "backlog", owner: "Teo Lindqvist" }, { id: "WS-101", title: "Write the release notes", status: "ready", meta: "Due 14 Sep", owner: "Mira Okafor" }, { id: "WS-108", title: "Rename the workspace settings", status: "ready", subtitle: "Design", owner: "Ana Ruiz" }, { id: "WS-102", title: "Rotate the signing keys", status: "doing", subtitle: "Ops", pill: { label: "Blocked", severity: "attention" }, owner: "Teo Lindqvist", }, { id: "WS-103", title: "Migrate the billing webhooks", status: "doing", meta: "Due 12 Sep", owner: "Ana Ruiz" }, { id: "WS-104", title: "Fix the export timeout", status: "review", meta: "Due 12 Sep", owner: "Mira Okafor" }, { id: "WS-106", title: "Ship the audit log", status: "done", pill: { label: "Shipped", severity: "positive" }, owner: "Ana Ruiz" }, ]; // SuiBoardCard[] const columns = [ { id: "backlog", heading: "Backlog" }, { id: "ready", heading: "Ready" }, { id: "doing", heading: "In progress" }, { id: "review", heading: "Review" }, { id: "done", heading: "Done" }, ]; // SuiBoardColumn[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiBoard aria-label="Launch board" group-by="status" :cards="cards" :columns="columns" @sui-card-click="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiBoard } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiBoard], template: `<sui-board aria-label="Launch board" group-by="status" [(collapsed)]="collapsed" [cards]="cards" [columns]="columns" (suiCardClick)="onChange($event)"></sui-board>`, }) export class Example { collapsed: SuiKey[] = []; cards = [ { id: "WS-105", title: "Draft the onboarding email", status: "backlog", meta: "Due 26 Sep", owner: "Mira Okafor" }, { id: "WS-107", title: "Add the keyboard shortcuts page", status: "backlog", owner: "Teo Lindqvist" }, { id: "WS-101", title: "Write the release notes", status: "ready", meta: "Due 14 Sep", owner: "Mira Okafor" }, { id: "WS-108", title: "Rename the workspace settings", status: "ready", subtitle: "Design", owner: "Ana Ruiz" }, { id: "WS-102", title: "Rotate the signing keys", status: "doing", subtitle: "Ops", pill: { label: "Blocked", severity: "attention" }, owner: "Teo Lindqvist", }, { id: "WS-103", title: "Migrate the billing webhooks", status: "doing", meta: "Due 12 Sep", owner: "Ana Ruiz" }, { id: "WS-104", title: "Fix the export timeout", status: "review", meta: "Due 12 Sep", owner: "Mira Okafor" }, { id: "WS-106", title: "Ship the audit log", status: "done", pill: { label: "Shipped", severity: "positive" }, owner: "Ana Ruiz" }, ]; // SuiBoardCard[] columns = [ { id: "backlog", heading: "Backlog" }, { id: "ready", heading: "Ready" }, { id: "doing", heading: "In progress" }, { id: "review", heading: "Review" }, { id: "done", heading: "Done" }, ]; // SuiBoardColumn[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

Each column is a listbox of option cards with one tab stop. Drag a card, or focus one and press Space to lift it: every keyboard step is announced in a live region, and one cancelable suiMove fires on the drop.

<sui-board id="bd-basic" aria-label="Launch board" group-by="status" style="height:420px"></sui-board>
// setup(root): properties and events setup(root) { const el = root.querySelector('#bd-basic'); el.columns = stages(); el.cards = work(); }

Limits and tones

A column limit shows in the count and turns to the attention tone once the count passes it; the board still allows the move, so a host that wants a hard limit refuses suiMove. A column tone draws the status dot in the ladder's five shapes.

<sui-board id="bd-limits" aria-label="Launch board with limits" style="height:420px"></sui-board>
// setup(root): properties and events setup(root) { const el = root.querySelector('#bd-limits'); el.columns = [ { id: 'backlog', heading: 'Backlog', tone: 'neutral' }, { id: 'ready', heading: 'Ready', tone: 'info', limit: 3 }, { id: 'doing', heading: 'In progress', tone: 'attention', limit: 1 }, { id: 'review', heading: 'Review', tone: 'info', limit: 2 }, { id: 'done', heading: 'Done', tone: 'positive' }, ]; el.cards = work(); }

Plain columns and compact cards

variant plain puts the headings, counts and cards straight on the ground, rules each heading with a hairline and shows the collapse control on hover and focus only. density runs compact, standard and comfortable: compact pads a card 8 by 10 at a 6px gap, so about seven cards show where five did.

<sui-board id="bd-plain" aria-label="Launch board, plain and compact" variant="plain" density="compact" style="height:420px"></sui-board>
// setup(root): properties and events setup(root) { const el = root.querySelector('#bd-plain'); el.columns = stages(); el.cards = work().concat([ { id: 'WS-109', title: 'Archive the old runbooks', status: 'backlog', meta: 'Due 30 Sep', owner: 'Ana Ruiz' }, { id: 'WS-110', title: 'Plan the next cycle', status: 'backlog', subtitle: 'Planning', owner: 'Mira Okafor' }, { id: 'WS-111', title: 'Review the onboarding copy', status: 'backlog', meta: 'Due 2 Oct', owner: 'Teo Lindqvist' }, { id: 'WS-112', title: 'Tune the search index', status: 'backlog', owner: 'Ana Ruiz' }, { id: 'WS-113', title: 'Add the usage report', status: 'backlog', meta: 'Due 9 Oct', owner: 'Mira Okafor' }, ]); el.collapsed = ['done']; }

Collapsed and empty columns

collapsed lists the columns drawn as strips; a strip still takes a drop at its end and, since the card lands out of sight, focus moves to its expand button. A keyboard lift skips strips. An empty column shows emptyMessage at rest; an empty string keeps the dashed target and drops the words.

<div style="display:grid;gap:10px"> <sui-checkbox id="bd-once" label="Say it once above the board"></sui-checkbox> <p id="bd-once-line" hidden style="margin:0;font:460 13px/1.5 var(--font-sans);color:var(--ink-muted)">No cards match the filter.</p> <sui-board id="bd-collapsed" aria-label="Launch board, two columns collapsed" empty-message="No cards" style="height:400px"></sui-board> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#bd-collapsed'); const line = root.querySelector('#bd-once-line'); el.columns = stages(); el.cards = work().filter((c) => c.status !== 'review'); el.collapsed = ['backlog', 'done']; root.querySelector('#bd-once').addEventListener('suiChange', (e) => { el.emptyMessage = e.detail ? '' : 'No cards'; line.hidden = !e.detail; }); }

Custom cards

cardRender draws the card content from the card and its column; the board keeps the drag, the keyboard lift and the focus. Return an element to compose library parts such as sui-avatar, built with root.ownerDocument so the same code runs on a server.

<sui-board id="bd-custom" aria-label="Launch board with owners" style="height:420px"></sui-board>
// setup(root): properties and events setup(root) { const el = root.querySelector('#bd-custom'); el.columns = stages(); el.cards = work(); const doc = root.ownerDocument; el.cardRender = (card) => { const box = doc.createElement('div'); box.style.cssText = 'display:grid;gap:6px;min-width:0'; const head = doc.createElement('div'); head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:8px'; const id = doc.createElement('code'); id.style.cssText = 'font:460 11px var(--font-mono);color:var(--ink-muted)'; id.textContent = card.id; const avatar = doc.createElement('sui-avatar'); avatar.setAttribute('name', card.owner); avatar.setAttribute('size', 'xs'); head.append(id, avatar); const title = doc.createElement('div'); title.style.cssText = 'font:550 13px/1.35 var(--font-sans);color:var(--ink)'; title.textContent = card.title; box.append(head, title); if (card.meta) { const meta = doc.createElement('div'); meta.style.cssText = 'font:460 11.5px var(--font-sans);color:var(--ink-muted)'; meta.textContent = card.meta; box.append(meta); } return box; }; }

Loading and without drag

loading draws three skeleton cards per column. no-drag removes the pointer drag and the insertion line for a touch surface where a long press means something else; the keyboard lift and move() remain, so drag is never the only path.

<div style="display:grid;gap:16px"> <sui-board id="bd-loading" aria-label="Loading board" loading style="height:220px"></sui-board> <sui-board id="bd-static" aria-label="Board without drag" no-drag style="height:260px"></sui-board> </div>
// setup(root): properties and events setup(root) { const loading = root.querySelector('#bd-loading'); loading.columns = stages().slice(1, 4); const fixed = root.querySelector('#bd-static'); fixed.columns = stages().slice(1, 4); fixed.cards = work().filter((c) => ['ready', 'doing', 'review'].includes(c.status)); }

Events

suiMove fires once per move from a drop, a keyboard lift or move(), with the card, both columns, both positions and whether the target is a collapsed strip. It is cancelable: here a Blocked card is refused a move into Done and stays put. suiCollapsedChange carries the collapsed ids and suiCardClick the opened card.

Move WS-101 to Review Nothing moved yet.
<div style="display:grid;gap:10px"> <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"> <sui-button id="bd-move" variant="outline" size="sm">Move WS-101 to Review</sui-button> <code id="bd-log" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">Nothing moved yet.</code> </div> <sui-board id="bd-events" aria-label="Launch board with a log" style="height:400px"></sui-board> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#bd-events'); const log = root.querySelector('#bd-log'); el.columns = stages(); el.cards = work(); el.addEventListener('suiMove', (e) => { const { id, from, fromIndex, to, index, collapsed } = e.detail; const card = el.cards.find((c) => c.id === id); if (to === 'done' && card.pill && card.pill.label === 'Blocked') { e.preventDefault(); log.textContent = `Refused: ${id} is blocked and cannot move to Done.`; return; } log.textContent = `${id} moved from ${from} (${fromIndex + 1}) to ${to} (${index + 1})${collapsed ? ', which is collapsed' : ''}.`; }); el.addEventListener('suiCollapsedChange', (e) => { log.textContent = e.detail.length ? `Collapsed: ${e.detail.join(', ')}.` : 'Every column open.'; }); el.addEventListener('suiCardClick', (e) => { log.textContent = `Opened ${e.detail.id}: ${e.detail.card.title}.`; }); root.querySelector('#bd-move').addEventListener('click', async () => { const moved = await el.move('WS-101', 'review'); if (!moved) log.textContent = 'WS-101 is already in Review.'; }); }

Keyboard

KeyAction
TabSteps through each column: its collapse button, then the card that holds that column's tab stop.
SpaceLifts the focused card, and drops a lifted card where it is.
EnterOpens the focused card (suiCardClick), and drops a lifted card.
ArrowDown ArrowUpMoves the tab stop to the next or previous card in the column. With a card lifted, moves the card one place.
ArrowRight ArrowLeftMoves the tab stop to the current card of the next or previous open column. With a card lifted, carries it into that column at the same position. Collapsed columns are skipped.
Home EndMoves to the first or last card in the column. With a card lifted, moves it to the top or bottom.
EscapeCancels a lift and returns the card to where it started.

API

<sui-board>

Properties

PropertyAttributeTypeDefaultDescription
cardRenderproperty only((card: SuiBoardCard, column: SuiBoardColumn) => SuiRenderResult) | undefinedDraws a card's content (card, column) => string | HTMLElement; strings render as text. Without it the card shows title, subtitle, meta and pill. A callback that throws costs only its card, which draws the built-in body, and the error is logged once. Property, not attribute.
cardsproperty onlySuiBoardCard[][]Cards, in the order they sit within their columns. Mutable: a completed move writes the card back with its new groupBy value at its new position, so a host that keeps its own copy updates it in suiMove. A card whose group matches no column is not drawn. Property, not attribute.
collapsedproperty onlySuiKey[][]Ids of the collapsed columns, each drawn as a narrow strip that still takes a drop. A card that lands in one is out of sight, so focus moves to the strip's expand button. Mutable; updated before suiCollapsedChange fires. Property, not attribute.
columnsproperty onlySuiBoardColumn[][]Columns in order. Each holds the cards whose groupBy field equals its id. Property, not attribute.
densitydensity"comfortable" | "compact" | "standard"'standard'Card spacing, on the scale the table, the grid and the list use. standard, the default, pads a card 10px by 12px with 8px between cards. compact takes the padding to 8px by 10px and the gap to 6px, so about seven cards show where five did; the title, subtitle, meta and pill all stay. comfortable pads 14px with 10px between cards.
emptyMessageempty-messagestringstring | undefinedText an empty column shows at rest. An empty string keeps the dashed target and drops the words, for a host that says it once above the board. While a card is in flight the column reads as a drop target instead.
groupBygroup-bystring'status'The card field that names its column. The board writes the target column's id into it on every move.
loadingloadingbooleanfalseDraws three skeleton cards per column instead of the cards, and no counts. Reflected, so a page can style the wait.
noDragno-dragbooleanfalseTurns off the pointer drag and its accent insertion line. The keyboard lift and move() remain, so drag is never the only path. Reflected.
variantvariant"card" | "plain"'card'Column chrome. card draws each column as a sunken, bordered well with its collapse control always showing; plain puts the headings, the counts and the cards straight on the ground, rules each heading with one hairline, and shows the collapse control on hover and focus of the heading only. A collapsed strip keeps its dashed edge while a card is over it either way.

Events

EventDetailReactVueDescription
suiCardClickSuiBoardCardClickDetailonSuiCardClick@sui-card-clickFires on a click or Enter on a card that is not lifted; detail is { id, card }.
suiCollapsedChangeSuiKey[]onSuiCollapsedChange@sui-collapsed-changeFires when a column collapses or expands; detail is the new collapsed. Does not bubble.
suiMoveSuiBoardMoveDetailonSuiMove@sui-moveFires before a card moves, from a drop, a keyboard lift or move() alike; detail is { id, from, fromIndex, to, index, collapsed }, where collapsed says the card is landing in a strip. Cancelable: preventDefault() refuses the move and the card stays where it was. Otherwise the board writes cards and moves the card.

Methods

MethodSignatureDescription
move()move(id: SuiKey, to: SuiKey, index?: number) => Promise<boolean>Moves a card to column to at index (the end when omitted) through the same cancelable suiMove a drop raises. Resolves true when the card moved, false when the id or column is unknown, the card is already there, or a listener refused it.
setFocus()setFocus() => Promise<void>Moves keyboard focus to the board's first tab stop: the current card of the first open column that has one.

Types

SuiBoardCard interfaceA card. The default renderer draws title, subtitle, meta and pill; the field named by groupBy places it in a column, and any other field is the host's to read in cardRender.
idSuiKeyIdentity, unique on the board, kept as given: a numeric id stays a number.
titlestring
subtitle?string
meta?string
pill?SuiBoardPill
SuiBoardCardClickDetail interfacesuiCardClick detail: the card's id and the card.
idSuiKey
cardSuiBoardCard
SuiBoardColumn interfaceOne column. It holds every card whose groupBy field equals its id.
idSuiKeyThe group value this column holds; a card lands here when card[groupBy] equals it, compared as text so a numeric status id matches. A move writes it into the card as given.
headingstringHeading text.
limit?numberWork-in-progress limit. The count reads n / limit and takes the attention tone past it. The board still allows the move; a host that wants a hard limit refuses suiMove.
tone?SuiSeverityStatus dot beside the heading, drawn by sui-badge in the ladder's five shapes.
SuiBoardMoveDetail interfacesuiMove detail: one card, where it was and where it is going.
idSuiKeyThe moving card's id.
fromSuiKeyColumn id the card leaves.
fromIndexnumberIts position in that column before the move, from zero.
toSuiKeyColumn id the card lands in; the same as from for a reorder.
indexnumberIts position in that column after the move, from zero.
collapsedbooleanTrue when that column is a collapsed strip, so the card lands out of sight and focus moves to the strip's expand button rather than to the card.
SuiBoardPill interfaceThe pill on a default card, painted by sui-tag from the status ladder.
labelstring
severity?SuiSeverity
SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.
SuiRenderResult type
No members.
SuiSeverity type
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also