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.

To Do3

Redesign onboarding flow

DesignUX
Jun 12

Audit colour token usage

Tokens
Jun 15

Write motion guidelines

Docs
Jun 18
In Progress2

Build KPI Tile component

ReactComponent
Jun 10

Implement Gauge arc math

SVGMath
Jun 11
In Review1

Progress Bar tokens

Tokens
Jun 9
Done4

Set up docs site

Infra
Jun 1

Define spacing scale

Tokens
Jun 3

Button component

React
Jun 5

Typography system

Tokens
Jun 7

Anatomy

ElementDescriptionToken / spec
Column headerStatus dot + uppercase label + count badge + add button12 px semibold, tracking 0.8
Accent strip1.5 px gradient bar below header (status colour → transparent)Status colour
Drop targetColumn fill and border shift to status colour on drag-over--border-subtle → status/0.4
Card surfaceRaised surface with card shadow; lifted on hover--surface-raised, --shadow-card
Card tagsCapsule pills, max 2 + overflow countSource colour / 0.15 background
Empty columnGhost label centred at 32 px vertical padding--text-tertiary

Animations

TriggerPropertyCurve
Card dropLayout reflowspring(response: 0.3, damping: 0.7)
Column hoverBackground + border coloureaseInOut 0.15s
Card hoverShadow 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>
  );
}