Kanban
A board of draggable cards in Well Gray columns, for pipelines and queues.
Bellweather Logistics
Cobalt Ridge Foods
Halcyon Health
Orchard Street Market
Meridian Parts Co.
Northwind Freight
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import { Kanban, type KanbanItems } from "@oration/canon/components/kanban";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { Tag, type TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { PlusIcon } from "lucide-react";import * as React from "react";export function Hero() { type Stage = { id: string; label: string; color: TagColor }; type Supplier = { id: string; name: string; tint: TagColor; owner: string; initials: string; spend: number; }; const stages: Stage[] = [ { id: "invited", label: "Invited", color: "gray" }, { id: "w9", label: "W-9 received", color: "blue" }, { id: "bank", label: "Bank verified", color: "teal" }, { id: "active", label: "Active", color: "green" }, ]; const [items, setItems] = React.useState<KanbanItems<Supplier>>({ invited: [ { id: "s1", name: "Bellweather Logistics", tint: "pink", owner: "Jordan Lee", initials: "JL", spend: 182_000, }, { id: "s2", name: "Cobalt Ridge Foods", tint: "violet", owner: "Wen Zhou", initials: "WZ", spend: 96_500, }, ], w9: [ { id: "s3", name: "Halcyon Health", tint: "teal", owner: "Tomás Ferreira", initials: "TF", spend: 418_000, }, ], bank: [ { id: "s4", name: "Orchard Street Market", tint: "green", owner: "Aisha Bello", initials: "AB", spend: 240_750, }, { id: "s5", name: "Meridian Parts Co.", tint: "amber", owner: "Priya Raman", initials: "PR", spend: 131_200, }, ], active: [ { id: "s6", name: "Northwind Freight", tint: "blue", owner: "Priya Raman", initials: "PR", spend: 1_284_300, }, ], }); const money = (value: number) => value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, }); return ( <div className="h-[26rem] w-full overflow-hidden"> <Kanban columns={stages} items={items} getItemId={(supplier) => supplier.id} onItemsChange={setItems} onItemMoved={(supplier, _from, to) => toast.add({ title: `${supplier.name} moved`, description: `Now in ${stages.find((s) => s.id === to)?.label}.`, }) } className="pb-2" renderColumnHeader={(stage, column) => ( <div className="flex items-center gap-2 px-3 pt-3 pb-2"> <Tag color={stage.color}>{stage.label}</Tag> <span className="text-xs text-muted-foreground tabular-nums"> {column.length} </span> <span className="ml-auto text-xs font-medium text-muted-foreground tabular-nums"> {money(column.reduce((sum, s) => sum + s.spend, 0))} </span> </div> )} renderColumnFooter={(stage) => stage.id === "invited" ? ( <div className="px-2 pb-2"> <Button type="button" variant="ghost" size="sm" className="w-full justify-start text-muted-foreground" onClick={() => toast.add({ title: "Invite supplier", description: "They'll get a link to upload a W-9.", }) } > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Invite supplier </Button> </div> ) : null } renderColumnEmpty={() => ( <div className="flex h-20 items-center justify-center rounded-xl border border-dashed border-border-strong px-4 text-center text-xs text-muted-foreground"> Drag a supplier here </div> )} renderItem={(supplier, { overlay }) => ( <article className={cn( "flex flex-col gap-2 rounded-xl bg-card p-3 shadow-border transition-shadow duration-150", overlay ? "shadow-border-hover" : "hover:shadow-border-hover", )} > <div className="flex items-center gap-2"> <MonogramTile name={supplier.name} color={supplier.tint} size="sm" /> <h3 className="truncate text-13 font-medium"> {supplier.name} </h3> </div> <div className="flex items-center justify-between text-xs text-muted-foreground"> <span className="flex items-center gap-1.5"> <Avatar size="sm"> <AvatarFallback> {supplier.initials} </AvatarFallback> </Avatar> {supplier.owner} </span> <span className="tabular-nums"> {money(supplier.spend)} </span> </div> </article> )} /> </div> );}Usage#
Kanban is a board of draggable cards in Well Gray columns, built on dnd-kit. It's the board view of deals and tickets in Oration, and the right shape for any queue where the column is the record's stage. It owns drag, drop, reordering and the drag overlay; you render the column header, the cards, the footer and the empty column. The thing people get wrong is the card: the whole card is the drag handle, so it should open its record through one stretched link, not a row of buttons.
When to use
- For records that move through stages, where the stage is the column: supplier onboarding, invoice exceptions, deals, tickets.
- As the board alternative to a list view of the same records, with the same filters.
- When people reorder work by priority inside a stage as well as moving it between stages.
- For up to a few dozen cards per column; past that, a grid with a stage filter reads faster.
When not to use
- For a dense record list with many fields to compare. Use Data grid
- For reordering a single list, such as statuses or attributes. Use Sortable list
- For showing progress through stages on one record. Use Stepper
- For a dashboard of unrelated cards. That's the card mosaic Canon refuses.
The Option Hue Rule
The Hairline-and-Lift Rule
The Tabular Figures Rule
Anatomy#
- Column. A 288px
<section>with 12px corners in Well Gray at 60% (40% in dark), and a scrolling body with 8px gaps. - Column header. From
renderColumnHeader: the stage tag, a count and a total, in 12px Slate Meta tabular figures. - Card. From
renderItem, wrapped in a focusable drag handle with a 12px focus ring. - Placeholder. The card's slot while it's dragged, at 40% opacity, showing where it will land.
- Column footer. From
renderColumnFooter: usually a ghost add button. - Drag overlay. A copy of the card under the pointer (
overlay: true), tilted 1.5° with a large shadow.
Examples#
Cards that open their record
A stretched link on the title opens the record; a 4px drag threshold keeps clicks working. draggable={false} stops the browser's own link drag, and the overlay copy renders the title as text.
Northwind Freight
Amount over PO by $1,240.00
Halcyon Health
Possible duplicate
Orchard Street Market
Missing W-9
import { Kanban, type KanbanItems } from "@oration/canon/components/kanban";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function LinkedCards() { type Exception = { id: string; invoice: string; supplier: string; reason: string; amount: string; }; const columns = [ { id: "new", label: "New" }, { id: "investigating", label: "Investigating" }, { id: "resolved", label: "Resolved" }, ]; const [items, setItems] = React.useState<KanbanItems<Exception>>({ new: [ { id: "e1", invoice: "INV-20931", supplier: "Northwind Freight", reason: "Amount over PO by $1,240.00", amount: "$18,240.00", }, { id: "e2", invoice: "INV-20944", supplier: "Halcyon Health", reason: "Possible duplicate", amount: "$4,600.00", }, ], investigating: [ { id: "e3", invoice: "INV-20877", supplier: "Orchard Street Market", reason: "Missing W-9", amount: "$2,115.50", }, ], resolved: [], }); return ( <div className="h-[22rem] w-full overflow-hidden"> <Kanban columns={columns} items={items} getItemId={(item) => item.id} onItemsChange={setItems} onItemMoved={(item, _from, to) => toast.add({ title: `${item.invoice} moved to ${columns.find((c) => c.id === to)?.label}`, }) } columnClassName="w-64" renderColumnHeader={(column, list) => ( <div className="flex items-center gap-2 px-3 pt-3 pb-2"> <span className="text-13 font-medium"> {column.label} </span> <span className="text-xs text-muted-foreground tabular-nums"> {list.length} </span> </div> )} renderColumnEmpty={() => ( <div className="flex h-20 items-center justify-center rounded-xl border border-dashed border-border-strong px-4 text-center text-xs text-muted-foreground"> No resolved exceptions this week </div> )} renderItem={(item, { overlay }) => ( <article className={cn( "relative flex flex-col gap-1.5 rounded-xl bg-card p-3 shadow-border transition-shadow duration-150", overlay ? "shadow-border-hover" : "hover:shadow-border-hover", )} > <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> <span className="font-mono">{item.invoice}</span> <span className="tabular-nums">{item.amount}</span> </div> <h3 className="text-13 leading-snug font-medium"> {overlay ? ( item.supplier ) : ( <a href={`#${item.invoice.toLowerCase()}`} draggable={false} onClick={() => toast.add({ title: `Opening ${item.invoice}`, }) } className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:underline" > {item.supplier} </a> )} </h3> <p className="text-xs text-muted-foreground"> {item.reason} </p> </article> )} /> </div> );}Narrow columns and empty columns
columnClassName="w-52" fits four stages without scrolling. An empty column keeps a dashed drop zone that says what belongs there.
Friday, Oct 2
212 invoices$684,210.00
Friday, Oct 9
64 invoices$190,400.00
Monday, Sep 28
38 invoices$121,055.00
Friday, Sep 25
198 invoices$602,775.00
import { Kanban, type KanbanItems } from "@oration/canon/components/kanban";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function NarrowColumns() { type Run = { id: string; date: string; invoices: number; amount: string }; const columns = [ { id: "scheduled", label: "Scheduled" }, { id: "sending", label: "Sending" }, { id: "paid", label: "Paid" }, { id: "returned", label: "Returned" }, ]; const [items, setItems] = React.useState<KanbanItems<Run>>({ scheduled: [ { id: "r1", date: "Friday, Oct 2", invoices: 212, amount: "$684,210.00", }, { id: "r2", date: "Friday, Oct 9", invoices: 64, amount: "$190,400.00", }, ], sending: [ { id: "r3", date: "Monday, Sep 28", invoices: 38, amount: "$121,055.00", }, ], paid: [ { id: "r4", date: "Friday, Sep 25", invoices: 198, amount: "$602,775.00", }, ], returned: [], }); return ( <div className="h-[19rem] w-full overflow-hidden"> <Kanban columns={columns} items={items} getItemId={(run) => run.id} onItemsChange={setItems} onItemMoved={(run, _from, to) => toast.add({ title: `${run.date} run marked ${columns.find((c) => c.id === to)?.label.toLowerCase()}`, }) } columnClassName="w-52" renderColumnHeader={(column, list) => ( <div className="flex items-center gap-2 px-3 pt-3 pb-2"> <span className="text-13 font-medium"> {column.label} </span> <span className="text-xs text-muted-foreground tabular-nums"> {list.length} </span> </div> )} renderColumnEmpty={() => ( <div className="flex h-16 items-center justify-center rounded-xl border border-dashed border-border-strong px-3 text-center text-xs text-muted-foreground"> No returned payments </div> )} renderItem={(run, { overlay }) => ( <article className={cn( "flex flex-col gap-1 rounded-xl bg-card p-3 shadow-border transition-shadow duration-150", overlay ? "shadow-border-hover" : "hover:shadow-border-hover", )} > <h3 className="text-13 font-medium">{run.date}</h3> <p className="flex justify-between text-xs text-muted-foreground tabular-nums"> <span>{run.invoices} invoices</span> <span>{run.amount}</span> </p> </article> )} /> </div> );}States#
import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() { return ( <div className="flex w-full flex-col gap-4"> <div className="flex flex-wrap justify-center gap-4"> {cardStates.map((state) => ( <div key={state.label} className="flex flex-col items-center gap-2" > <div className={cn( "flex w-40 flex-col gap-1 rounded-xl bg-card p-3", state.className, )} > <span className="text-13 font-medium"> Halcyon Health </span> <span className="text-xs text-muted-foreground tabular-nums"> $418,000 </span> </div> <span className="text-xs text-muted-foreground"> {state.label} </span> </div> ))} </div> <div className="flex flex-wrap justify-center gap-4"> {[ { label: "Column", className: "bg-muted/60 dark:bg-muted/40", }, { label: "Column, drop target", className: "bg-muted" }, ].map((column) => ( <div key={column.label} className="flex flex-col items-center gap-2" > <div className={cn( "h-24 w-48 rounded-xl", column.className, )} /> <span className="text-xs text-muted-foreground"> {column.label} </span> </div> ))} </div> </div> );}| State | Treatment |
|---|---|
| Rest | Cards with hairline lift on the column. |
| Hover | Cards deepen to the hover lift over 150ms (set in your card). |
| Focus visible | A 3px Focus Indigo ring at 40% around the card wrapper. |
| Dragging | The slot drops to 40% opacity, the overlay follows the pointer, and other cards slide aside. |
| Drop target | The column under the card fills Well Gray at 100% over 150ms. |
| Dropped | The overlay settles into the slot over 200ms on the house ease-out. |
| Empty column | From renderColumnEmpty: a dashed Firm Hairline box with a line of Slate Meta. |
Behavior#
- The board is controlled.
itemsmaps column ids to arrays; every move callsonItemsChangewith the next map, and you store it. - A drag starts after the pointer moves 4px, so a click on a card still opens it.
- Moving across columns updates
itemswhile dragging, so the card's slot follows the pointer. On drop,onItemMoved(item, from, to)fires once if the column changed; reorders within a column only callonItemsChange. - From the keyboard, focus a card and press Space or Enter to pick it up, the arrow keys to move it within and across columns, Space or Enter to drop, and Escape to cancel.
- Collision uses the closest corners, so a card dropped over an empty column lands in it.
- Columns are 288px by default; pass
columnClassNameto change the width. The board scrolls sideways when the columns don't fit, and each column scrolls on its own.
Do and don't#
draggable={false} so the link doesn't start a native drag.Content#
- Column headers use the stage's name exactly as it appears on the record: W-9 received, not W9 Rcvd.
- Card titles are the record's name; one line of meta below it (owner, amount, reason).
- Empty columns say what belongs there or what to do: Drag a supplier here, No resolved exceptions this week.
- Confirm a move in a toast that names the record and its new stage.
Accessibility#
- Each card wrapper is a focusable
role="button"witharia-roledescription="sortable"and instructions from dnd-kit, so it can be picked up from the keyboard. - dnd-kit announces pick up, move and drop in a live region. The board doesn't pass custom announcements, so they read item ids, not names; see known gaps.
- Put the record link on the card's title and stretch it over the card. Keep other controls out of the card.
- Columns are
<section>elements without a name. Put the stage name in the header as text, not only as color. - Every move must also be possible without dragging: put the stage on the record page or in a menu.
| Keys | Action |
|---|---|
| Tab | Moves between cards, links and column buttons. |
| Space | Picks up the focused card, or drops it. |
| Enter | Picks up or drops the focused card. |
| ↑↓ | Moves the card within its column while held. |
| ←→ | Moves the card to the neighboring column while held. |
| Esc | Cancels the drag. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Columns at 60% (40% in dark), the drop target at 100% |
--card | Card surface (in your card) |
shadow-border, shadow-border-hover | Card lift at rest and on hover or drag |
shadow-lg | The drag overlay |
--ring | Focus ring at 40% |
--border-strong | Dashed empty-column outline (in your empty state) |
--radius-xl | 12px columns and cards |
API reference#
Kanban
The board. Generic over the column type C and the item type T.
| Prop | Type | Default | Description |
|---|---|---|---|
columnsRequired | C[] (C extends { id: string }) | No default | Columns in order. |
itemsRequired | KanbanItems<T> | No default | Items per column id, in order. |
getItemIdRequired | (item: T) => string | No default | A stable id for each item. |
onItemsChangeRequired | (items: KanbanItems<T>) => void | No default | Called with the next map on every move. |
onItemMoved | (item: T, fromColumn: string, toColumn: string) => void | No default | Called once on drop when an item changed columns. |
renderItemRequired | (item: T, state: { overlay: boolean }) => ReactNode | No default | The card. overlay is true for the copy under the pointer. |
renderColumnHeaderRequired | (column: C, items: T[]) => ReactNode | No default | The column's header. |
renderColumnFooter | (column: C, items: T[]) => ReactNode | No default | Below the cards, such as an add button. |
renderColumnEmpty | (column: C) => ReactNode | No default | Shown when a column has no items. |
className | string | No default | Applied to the board's scrolling row. |
columnClassName | string | No default | Applied to every column, such as a width. |
KanbanItems
Record<string, T[]>: the board's state, keyed by column id.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Pressing Escape after dragging a card into another column leaves it there: the move was already committed through onItemsChange during the drag, onDragCancel doesn't restore it, and onItemMoved never fires.
Screen-reader announcements are dnd-kit's defaults, which read ids such as Picked up draggable item s4. There's no prop to name items.
The whole card is the drag handle (role="button"), so links and buttons inside it are nested interactive content. Deal cards put their link inside it.
The drag overlay tilts 1.5° with shadow-lg, where DESIGN.md says a dragged card takes the hover lift. Neither the tilt nor the 200ms drop animation is reduced for reduced-motion users.
Columns are unnamed <section> elements, and their width is fixed at 288px unless every caller passes columnClassName.