Validate against the staging IDP; fall back to existing token endpoint.
- due:May 12
Drag-and-drop kanban board with swimlanes, tinted columns, per-column rules, and a renderer registry that hosts any card type.
Use when you need a Trello/Linear/JIRA-style board for tracking work, pipelines, or stage transitions. The renderer-registry pattern lets a single column mix the lightweight built-in `kanban-card`, the `kanban-note` annotation, and any rich card from elsewhere in this registry — all as siblings in an ordered, JSON-serializable item list. CRUD affordances are opt-in via callbacks; DnD via @dnd-kit (touch + keyboard accessible).
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/kanban-boardAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/kanban-board-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Validate against the staging IDP; fall back to existing token endpoint.
Coordinate with the auth team on the new session shape before merging.
Tighten spacing on the notifications panel.
Decide between LRU and ARC for the new session cache layer.
Posted in #infra-incidents — link in the action items list.
"use client"; import { useCallback, useMemo, useState } from "react";import { CardTree } from "@/registry/components/data/card-tree";import type { CardTreeJsonNode } from "@/registry/components/data/card-tree";import { KanbanBoard } from "./kanban-board";import { kanbanCardRenderer } from "./parts/kanban-card";import { kanbanNoteRenderer } from "./parts/kanban-note";import type { KanbanCardRenderer, KanbanData } from "./types"; // Rich-card adapter — registers the full <CardTree> as a kanban renderer.// Items with rendererId="card-tree" carry a CardTreeJsonNode. We use// dragHandle="header" so the kanban grip strip handles outer reorder while the// CardTree body keeps its click-to-edit + internal DnD intact.function makeCardTreeRenderer( onItemDataChange: (itemId: string, next: CardTreeJsonNode) => void,): KanbanCardRenderer<CardTreeJsonNode> { return { id: "card-tree", label: "Card tree", dragHandle: "header", render: (data, ctx) => ( <div className="rounded-b-md border-x border-b border-border bg-card text-card-foreground shadow-xs"> <CardTree key={ctx.itemId} defaultValue={data} editable defaultCollapsed={(level) => level >= 1} metaPresentation="popover" onChange={(tree) => onItemDataChange(ctx.itemId, tree)} aria-label={`Card tree item ${ctx.itemId}`} className="text-xs" /> </div> ), newItem: () => ({ __rcid: `rc-${Date.now().toString(36)}`, title: "New rich card", }), };} const INITIAL_DATA: KanbanData = { swimlanes: [ { id: "lane-platform", title: "Platform" }, { id: "lane-product", title: "Product" }, ], columns: [ { id: "col-todo", title: "To do", color: "slate", items: [ { id: "item-1", rendererId: "kanban-card", swimlaneId: "lane-platform", data: { title: "Wire OAuth flow for new SDK", description: "Validate against the staging IDP; fall back to existing token endpoint.", tags: [{ label: "auth" }, { label: "platform" }], assignees: [ { id: "u-ada", name: "Ada Lovelace" }, { id: "u-bo", name: "Bo Yang" }, ], meta: [{ key: "due", label: "due", value: "May 12" }], }, }, { id: "item-rich-1", rendererId: "card-tree", swimlaneId: "lane-product", data: { __rcid: "rich-onboarding", __rcmeta: { owner: "design", created: "2026-04-29", priority: "high", }, title: "Onboarding flow v2", summary: "Replace splash modal with an inline tour", estimated_hours: 16, blocked: false, quote: "Friction in the first 30 seconds defines the whole product.", list: [ "audit current funnel", "wireframe v2", "prototype", "user-test", "ship", ], requirements: { __rcid: "rich-onboarding-req", __rcmeta: { source: "PM brief", revision: 3 }, browsers: ["Chrome", "Safari", "Firefox"], minimum_version: "ES2020", codearea: { format: "ts", content: "type OnboardingStep =\n | 'welcome'\n | 'connect'\n | 'invite'\n | 'done';\n\ninterface FlowState {\n step: OnboardingStep;\n startedAt: string;\n}", }, }, metrics: { __rcid: "rich-onboarding-metrics", kpi: "completion rate", baseline: 0.41, target: 0.62, table: { headers: ["step", "drop-off (v1)", "target"], rows: [ ["welcome", 0.08, 0.05], ["connect", 0.31, 0.15], ["invite", 0.18, 0.1], ], }, }, } satisfies CardTreeJsonNode, }, { id: "item-2", rendererId: "kanban-note", swimlaneId: "lane-platform", data: { title: "Reminder", body: "Coordinate with the auth team on the new session shape before merging.", }, }, ], }, { id: "col-doing", title: "In progress", color: "lime", maxItems: 3, items: [ { id: "item-rich-2", rendererId: "card-tree", swimlaneId: "lane-platform", data: { __rcid: "rich-migration", __rcmeta: { ticket: "PLAT-482", risk: "medium", window: "2026-05-09T22:00:00Z", }, title: "Migrate session store", stage: "implementation", rollback_ready: true, image: { src: "https://images.unsplash.com/photo-1518770660439-4636190af475?w=600&q=70", alt: "Architecture diagram", }, steps: { __rcid: "rich-migration-steps", expected_duration_minutes: 45, list: [ "snapshot current store", "drain in-flight sessions", "swap connection pool", "verify auth probe", "monitor 30m", ], }, schema: { __rcid: "rich-migration-schema", codearea: { format: "sql", content: "ALTER TABLE sessions\n ADD COLUMN store_version SMALLINT NOT NULL DEFAULT 2;\n\nCREATE INDEX CONCURRENTLY idx_sessions_v2\n ON sessions (user_id, store_version);", }, }, } satisfies CardTreeJsonNode, }, { id: "item-4", rendererId: "kanban-card", swimlaneId: "lane-product", data: { title: "Empty-state polish", description: "Tighten spacing on the notifications panel.", tags: [{ label: "design" }], assignees: [{ id: "u-cn", name: "Cy Nguyen" }], }, }, ], }, { id: "col-review", title: "Review", color: "sky", items: [ { id: "item-rich-3", rendererId: "card-tree", swimlaneId: "lane-product", data: { __rcid: "rich-spec", __rcmeta: { reviewer: "Ada Lovelace", round: 2 }, title: "API spec — billing v3", status: "in-review", breaking_changes: false, quote: "If we get the deprecation window right, nobody notices the migration.", endpoints: { __rcid: "rich-spec-endpoints", count: 6, table: { headers: ["method", "path", "auth"], rows: [ ["GET", "/v3/invoices", "scope:read"], ["POST", "/v3/invoices", "scope:write"], ["GET", "/v3/customers/:id", "scope:read"], ["DELETE", "/v3/invoices/:id", "scope:write"], ], }, }, sample: { __rcid: "rich-spec-sample", codearea: { format: "json", content: '{\n "id": "inv_82a",\n "amount_cents": 12500,\n "currency": "EUR",\n "due": "2026-05-30"\n}', }, }, notes: ["soft-deprecate v2 by EOY", "x-version header optional"], } satisfies CardTreeJsonNode, }, { id: "item-5", rendererId: "kanban-card", swimlaneId: "lane-platform", data: { title: "Cache eviction policy", description: "Decide between LRU and ARC for the new session cache layer.", tags: [{ label: "perf" }, { label: "infra" }], assignees: [{ id: "u-bo", name: "Bo Yang" }], meta: [{ key: "due", label: "due", value: "May 9" }], }, }, ], }, { id: "col-done", title: "Done", color: "emerald", allowReorder: false, items: [ { id: "item-6", rendererId: "kanban-card", swimlaneId: "lane-product", data: { title: "Empty-state illustrations", tags: [{ label: "design" }], assignees: [{ id: "u-fe", name: "Farah Eid" }], }, }, { id: "item-7", rendererId: "kanban-note", swimlaneId: "lane-platform", data: { title: "Postmortem ready", body: "Posted in #infra-incidents — link in the action items list.", }, }, ], }, ],}; export default function KanbanBoardDemo() { const [data, setData] = useState<KanbanData>(INITIAL_DATA); // Rich-card renderer fires this whenever its inner state changes — we walk // the board and replace the matching item's data so kanban stays the source of truth. const updateCardTreeData = useCallback( (itemId: string, next: CardTreeJsonNode) => { setData((prev) => ({ ...prev, columns: prev.columns.map((col) => ({ ...col, items: col.items.map((it) => it.id === itemId ? { ...it, data: next } : it, ), })), })); }, [], ); const renderers = useMemo( () => [ kanbanCardRenderer, kanbanNoteRenderer, makeCardTreeRenderer(updateCardTreeData), ], [updateCardTreeData], ); return ( <div className="h-160 w-full overflow-hidden rounded-md border border-border bg-background"> <KanbanBoard renderers={renderers} data={data} onChange={setData} /> </div> );} Reach for KanbanBoard when you need a column-based board with drag-and-drop reordering, optional swimlanes, optional CRUD, and the flexibility to host any card from this registry (or your own custom components) as first-class items in any column.
items[] is an array of { id, rendererId, data, swimlaneId?, locked? } records. The board never holds JSX in its data layer.kanbanCardRenderer, kanbanNoteRenderer); register more via the renderers prop. Each declares an id and a render(data, ctx).onItemCreateand an inline "+ Add" row appears under each column. Same pattern for edit, delete, and column CRUD callbacks.import { KanbanBoard } from "@/components/kanban-board";
import { kanbanCardRenderer } from "@/components/kanban-board/parts/kanban-card";
import { kanbanNoteRenderer } from "@/components/kanban-board/parts/kanban-note";
export function Example() {
return (
<KanbanBoard
renderers={[kanbanCardRenderer, kanbanNoteRenderer]}
defaultData={{
columns: [
{
id: "todo",
title: "To do",
items: [
{ id: "c1", rendererId: "kanban-card", data: { title: "Wire auth flow" } },
{ id: "n1", rendererId: "kanban-note", data: { title: "Reminder", body: "Coordinate with infra." } },
],
},
{ id: "doing", title: "In progress", color: "lime", items: [] },
{ id: "done", title: "Done", color: "emerald", items: [], allowReorder: false },
],
}}
/>
);
}column.allowReorder: false — items cannot reorder within this column.column.allowIncoming: false — items cannot be dropped into this column from elsewhere.column.allowOutgoing: false — items cannot leave this column.column.acceptsRendererIds: [...] — only host the listed renderer kinds.item.locked: true — pin an individual item; it cannot be dragged anywhere.readOnly at the board level kills all DnD and CRUD affordances; items remain clickable.Any sibling registry component can be plugged in as a third renderer with all of its features intact. The demo wires <CardTree> from @ilinxa/card-tree — same pattern works for any rich card you author. Set dragHandle: "header" for renderers that own internal pointer interactions so the kanban grip appears on top and the body stays interactive:
import { CardTree, type CardTreeJsonNode } from "@ilinxa/card-tree";
function makeCardTreeRenderer(
onChange: (id: string, next: CardTreeJsonNode) => void,
): KanbanCardRenderer<CardTreeJsonNode> {
return {
id: "card-tree",
label: "Card tree",
dragHandle: "header", // ← thin grip strip; body stays interactive
render: (data, ctx) => (
<CardTree
key={ctx.itemId}
defaultValue={data}
editable
onChange={(tree) => onChange(ctx.itemId, tree)}
/>
),
};
}
<KanbanBoard
renderers={[kanbanCardRenderer, kanbanNoteRenderer, makeCardTreeRenderer(updateData)]}
defaultData={{ /* items reference rendererId: "kanban-card" | "kanban-note" | "card-tree" */ }}
/>dragHandle modes: "shell" (default) makes the whole card the drag activator — right for plain content cards. "header" renders a small grip strip on top and leaves the body fully interactive — right for renderers with click-to-edit fields, embedded inputs, or their own internal DnD.
Pre-built renderer — todo items: @ilinxa/task-card exports a ready-to-use taskCardKanbanRenderer (typed as KanbanCardRenderer<TaskItem>, dragHandle: "header") — no factory wrapper needed. Drop it directly into renderers={[...]} and give each item rendererId: "task-card" with a TaskItem-shaped data payload. See the task-card detail page for the live kanban demo + the full code recipe.
Tab cycles focus through items and column headers.Space on a focused item lifts it (DnD mode); arrow keys move; Space drops; Escape cancels.Enter on a focused item fires onItemClick.