Skip to content

Kanban

A board of draggable cards in Well Gray columns, for pipelines and queues.

Status
Beta
Category
Data display
Adoption
Not used yet
import { Kanban } from "@oration/canon/components/kanban";
packages/canon/src/components/kanban.tsx
Invited2$278,500

Bellweather Logistics

JLJordan Lee$182,000

Cobalt Ridge Foods

WZWen Zhou$96,500
W-9 received1$418,000

Halcyon Health

TFTomás Ferreira$418,000
Bank verified2$371,950

Orchard Street Market

ABAisha Bello$240,750

Meridian Parts Co.

PRPriya Raman$131,200
Active1$1,284,300

Northwind Freight

PRPriya Raman$1,284,300
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

A stage column's header may carry the stage as a Tag in its option hue. The column itself stays Well Gray; never tint a whole column or give cards colored edges.

The Hairline-and-Lift Rule

Cards are Card White with hairline lift and 12px corners, deepening to the hover lift on hover and while dragged. No borders.

The Tabular Figures Rule

Column counts and totals and the amounts on cards are tabular, so they don't jitter as cards move.

Anatomy#

Bank verified2$371,950
Orchard Street MarketAisha Bello
Meridian Parts Co.Priya Raman
Invite supplier
Meridian Parts Co.Priya Raman
  1. Column. A 288px <section> with 12px corners in Well Gray at 60% (40% in dark), and a scrolling body with 8px gaps.
  2. Column header. From renderColumnHeader: the stage tag, a count and a total, in 12px Slate Meta tabular figures.
  3. Card. From renderItem, wrapped in a focusable drag handle with a 12px focus ring.
  4. Placeholder. The card's slot while it's dragged, at 40% opacity, showing where it will land.
  5. Column footer. From renderColumnFooter: usually a ghost add button.
  6. 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.

New2
Investigating1
Resolved0
No resolved exceptions this week
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.

Scheduled2

Friday, Oct 2

212 invoices$684,210.00

Friday, Oct 9

64 invoices$190,400.00

Sending1

Monday, Sep 28

38 invoices$121,055.00

Paid1

Friday, Sep 25

198 invoices$602,775.00

Returned0
No returned payments
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#

Halcyon Health$418,000
Rest
Halcyon Health$418,000
Hover
Halcyon Health$418,000
Focus visible
Halcyon Health$418,000
Placeholder
Halcyon Health$418,000
Dragging
Column
Column, drop target
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>    );}
States
StateTreatment
RestCards with hairline lift on the column.
HoverCards deepen to the hover lift over 150ms (set in your card).
Focus visibleA 3px Focus Indigo ring at 40% around the card wrapper.
DraggingThe slot drops to 40% opacity, the overlay follows the pointer, and other cards slide aside.
Drop targetThe column under the card fills Well Gray at 100% over 150ms.
DroppedThe overlay settles into the slot over 200ms on the house ease-out.
Empty columnFrom renderColumnEmpty: a dashed Firm Hairline box with a line of Slate Meta.

Behavior#

  • The board is controlled. items maps column ids to arrays; every move calls onItemsChange with 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 items while 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 call onItemsChange.
  • 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 columnClassName to change the width. The board scrolls sideways when the columns don't fit, and each column scrolls on its own.

Do and don't#

Bank verified1
Orchard Street Market
Do. Keep columns Well Gray and show the stage as a Tag in the header, with a count and a total.
Bank verified
Orchard Street Market
Don't. Tint whole columns in the stage hue, uppercase the header and add colored left borders to cards.
Do. Give each card one stretched link to its record, with draggable={false} so the link doesn't start a native drag.
Don't. Pack a card with buttons. Every control inside the drag handle competes with dragging and clutters the tab order.

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" with aria-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.
Keyboard interactions
KeysAction
TabMoves between cards, links and column buttons.
SpacePicks up the focused card, or drops it.
EnterPicks up or drops the focused card.
↑↓Moves the card within its column while held.
←→Moves the card to the neighboring column while held.
EscCancels the drag.

Design tokens#

Design tokens
TokenUsed for
--mutedColumns at 60% (40% in dark), the drop target at 100%
--cardCard surface (in your card)
shadow-border, shadow-border-hoverCard lift at rest and on hover or drag
shadow-lgThe drag overlay
--ringFocus ring at 40%
--border-strongDashed empty-column outline (in your empty state)
--radius-xl12px columns and cards

API reference#

Kanban

The board. Generic over the column type C and the item type T.

Props of Kanban
PropTypeDefaultDescription
columnsRequiredC[] (C extends { id: string })No defaultColumns in order.
itemsRequiredKanbanItems<T>No defaultItems per column id, in order.
getItemIdRequired(item: T) => stringNo defaultA stable id for each item.
onItemsChangeRequired(items: KanbanItems<T>) => voidNo defaultCalled with the next map on every move.
onItemMoved(item: T, fromColumn: string, toColumn: string) => voidNo defaultCalled once on drop when an item changed columns.
renderItemRequired(item: T, state: { overlay: boolean }) => ReactNodeNo defaultThe card. overlay is true for the copy under the pointer.
renderColumnHeaderRequired(column: C, items: T[]) => ReactNodeNo defaultThe column's header.
renderColumnFooter(column: C, items: T[]) => ReactNodeNo defaultBelow the cards, such as an add button.
renderColumnEmpty(column: C) => ReactNodeNo defaultShown when a column has no items.
classNamestringNo defaultApplied to the board's scrolling row.
columnClassNamestringNo defaultApplied 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.