New
Kanban Board
A horizontal multi-column board for visualising and managing work items through stages. Composes Card, Badge, and drag-and-drop primitives.
Demo
Drag cards between columns. The column highlights when it is a valid drop target.
Anatomy
| Element | Description | Token / spec |
|---|---|---|
| Column header | Status dot + uppercase label + count badge + add button | 12 px semibold, tracking 0.8 |
| Accent strip | 1.5 px gradient bar below header (status colour → transparent) | Status colour |
| Drop target | Column fill and border shift to status colour on drag-over | --border-subtle → status/0.4 |
| Card surface | Raised surface with card shadow; lifted on hover | --surface-raised, --shadow-card |
| Card tags | Capsule pills, max 2 + overflow count | Source colour / 0.15 background |
| Empty column | Ghost label centred at 32 px vertical padding | --text-tertiary |
Animations
| Trigger | Property | Curve |
|---|---|---|
| Card drop | Layout reflow | spring(response: 0.3, damping: 0.7) |
| Column hover | Background + border colour | easeInOut 0.15s |
| Card hover | Shadow level + translateY(−1px) | easeOut 0.12s |
Context menu (desktop)
On macOS and right-click desktop, each card exposes a context menu for keyboard-driven column changes. See the Context Menu component.
- Group 1: Move to … — one item per column (excluding current)
- Divider
- Destructive: Delete (danger colour, requires confirmation)
Implementation
KanbanBoard.tsx
tsx
"use client";
import { useState, DragEvent } from "react";
interface KanbanCard {
id: string;
title: string;
tags?: string[];
date?: string;
}
interface KanbanColumn {
id: string;
label: string;
color: string;
cards: KanbanCard[];
}
export function KanbanBoard({ columns: initial }: { columns: KanbanColumn[] }) {
const [columns, setColumns] = useState(initial);
const [dragging, setDragging] = useState<{ cardId: string; fromCol: string } | null>(null);
const [target, setTarget] = useState<string | null>(null);
function handleDragStart(cardId: string, fromCol: string) {
setDragging({ cardId, fromCol });
}
function handleDrop(toColId: string) {
if (!dragging || dragging.fromCol === toColId) { setDragging(null); setTarget(null); return; }
setColumns((cols) => {
const next = cols.map((col) => ({ ...col, cards: [...col.cards] }));
const from = next.find((c) => c.id === dragging.fromCol)!;
const to = next.find((c) => c.id === toColId)!;
const cardIdx = from.cards.findIndex((c) => c.id === dragging.cardId);
const [card] = from.cards.splice(cardIdx, 1);
to.cards.push(card);
return next;
});
setDragging(null);
setTarget(null);
}
return (
<div style={{ display: "flex", gap: 12, overflowX: "auto", padding: "4px 0" }}>
{columns.map((col) => (
<div
key={col.id}
onDragOver={(e) => { e.preventDefault(); setTarget(col.id); }}
onDrop={() => handleDrop(col.id)}
onDragLeave={() => setTarget(null)}
style={{
minWidth: 240,
borderRadius: 12,
background: target === col.id
? `${col.color}18`
: "rgb(var(--surface))",
border: `1px solid ${target === col.id ? col.color + "60" : "rgb(var(--border))"}`,
transition: "background 0.15s, border-color 0.15s",
overflow: "hidden",
}}
>
{/* Column header */}
<div style={{ padding: "12px 14px 10px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: col.color }} />
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.05em", textTransform: "uppercase" }}>
{col.label}
</span>
<span style={{
marginLeft: "auto",
fontSize: 11,
fontWeight: 600,
padding: "2px 7px",
borderRadius: 99,
background: col.color + "20",
color: col.color,
}}>
{col.cards.length}
</span>
</div>
{/* Accent gradient strip */}
<div style={{
marginTop: 8,
height: 2,
borderRadius: 1,
background: `linear-gradient(to right, ${col.color}, transparent)`,
}} />
</div>
{/* Cards */}
<div style={{ padding: "0 10px 10px", display: "flex", flexDirection: "column", gap: 8 }}>
{col.cards.length === 0 && (
<div style={{ textAlign: "center", padding: "32px 0", color: "rgb(var(--text-tertiary))", fontSize: 12 }}>
Drop here
</div>
)}
{col.cards.map((card) => (
<div
key={card.id}
draggable
onDragStart={() => handleDragStart(card.id, col.id)}
style={{
padding: "10px 12px",
borderRadius: 8,
background: "rgb(var(--surface-raised))",
border: "1px solid rgb(var(--border-subtle))",
cursor: "grab",
boxShadow: "var(--shadow-card)",
opacity: dragging?.cardId === card.id ? 0.4 : 1,
}}
>
<p style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}>{card.title}</p>
{card.tags && (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{card.tags.slice(0, 2).map((tag) => (
<span key={tag} style={{
fontSize: 11, padding: "2px 7px", borderRadius: 99,
background: tagColor(tag) + "20", color: tagColor(tag), fontWeight: 500,
}}>{tag}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
);
}