A kanban sprint board of drag-reorderable issue cards across columns, each with a work-in-progress count that flags over-limit columns.
npx shadcn@latest add @paragon/sprint-boardAlso installs: kanban-column
"use client";
import * as React from "react";
import {
AlertTriangle,
SignalHigh,
SignalMedium,
SignalLow,
Minus,
} from "lucide-react";
import {
KanbanCard,
KanbanCardList,
KanbanColumn,
} from "@/registry/paragon/ui/kanban-column";
import { cn } from "@/lib/utils";
export type SprintPriority = "urgent" | "high" | "medium" | "low" | "none";
export interface SprintIssue {
id: string;
title: string;
priority?: SprintPriority;
/** Assignee display name — seeds initials + hue. */
assignee?: string;
/** Story-point estimate shown as a trailing chip. */
points?: number;
}
export interface SprintColumn {
id: string;
title: string;
issues: SprintIssue[];
/** Optional work-in-progress limit; the count turns amber when exceeded. */
wip?: number;
}
export interface SprintBoardProps extends React.ComponentProps<"div"> {
columns?: SprintColumn[];
}
const priorityMeta: Record<
SprintPriority,
{ icon: React.ElementType; className: string; label: string }
> = {
urgent: { icon: AlertTriangle, className: "text-destructive", label: "Urgent" },
high: { icon: SignalHigh, className: "text-foreground", label: "High" },
medium: { icon: SignalMedium, className: "text-foreground", label: "Medium" },
low: { icon: SignalLow, className: "text-muted-foreground", label: "Low" },
none: { icon: Minus, className: "text-muted-foreground", label: "No priority" },
};
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "•";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function hueOf(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) % 360;
}
const DEFAULTS: SprintColumn[] = [
{
id: "todo",
title: "Todo",
issues: [
{ id: "ENG-479", title: "Keyboard nav for command palette", priority: "high", assignee: "Marcus Kane", points: 3 },
{ id: "ENG-468", title: "Export audit log as CSV", priority: "low", assignee: "Dana Ortiz", points: 2 },
],
},
{
id: "in-progress",
title: "In Progress",
wip: 2,
issues: [
{ id: "ENG-482", title: "Signed webhooks reject on clock skew", priority: "urgent", assignee: "Priya Raghavan", points: 5 },
{ id: "ENG-471", title: "Dashboard p95 latency regression", priority: "medium", assignee: "Jamie Lin", points: 3 },
{ id: "ENG-465", title: "Migrate seats to per-org billing", priority: "high", assignee: "Marcus Kane", points: 8 },
],
},
{
id: "review",
title: "In Review",
issues: [
{ id: "ENG-460", title: "Retire legacy v1 reconciliation job", priority: "medium", assignee: "Dana Ortiz", points: 2 },
],
},
{
id: "done",
title: "Done",
issues: [
{ id: "ENG-455", title: "Add SSO domain verification", priority: "high", assignee: "Priya Raghavan", points: 5 },
{ id: "ENG-451", title: "Rate-limit the public search API", priority: "low", assignee: "Jamie Lin", points: 3 },
],
},
];
/**
* A sprint board: horizontally scrolling kanban columns built on
* `kanban-column`, each with a work-in-progress count that turns amber when a
* column exceeds its WIP limit. Cards carry a priority glyph, title, story
* points and an assignee chip, and stay drag-reorderable within a column
* (Alt+Arrow from the keyboard). Column ordering is local state so the demo
* remounts cleanly on replay.
*/
export function SprintBoard({
columns = DEFAULTS,
className,
...props
}: SprintBoardProps) {
const [state, setState] = React.useState(columns);
const reorder = (columnId: string, next: SprintIssue[]) => {
setState((current) =>
current.map((c) => (c.id === columnId ? { ...c, issues: next } : c)),
);
};
return (
<div
data-slot="sprint-board"
className={cn(
"flex w-full gap-3 overflow-x-auto pb-2",
"[scrollbar-width:thin]",
className,
)}
{...props}
>
{state.map((column) => {
const over = column.wip !== undefined && column.issues.length > column.wip;
return (
<div key={column.id} className="w-64 shrink-0">
<KanbanColumn title={column.title} count={column.issues.length}>
{column.wip !== undefined && (
<span
className={cn(
"mb-2 inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums",
over
? "bg-warning/15 text-warning"
: "bg-secondary text-muted-foreground",
)}
>
{column.issues.length} / {column.wip} WIP
</span>
)}
<KanbanCardList
values={column.issues}
onReorder={(next) => reorder(column.id, next)}
>
{column.issues.map((issue) => {
const p = priorityMeta[issue.priority ?? "none"];
const PriorityIcon = p.icon;
return (
<KanbanCard key={issue.id} value={issue}>
<div className="flex items-start gap-2">
<span
aria-label={p.label}
title={p.label}
className={cn("mt-0.5 shrink-0", p.className)}
>
<PriorityIcon className="size-3.5" aria-hidden />
</span>
<p className="text-sm leading-snug font-medium">
{issue.title}
</p>
</div>
<div className="mt-2.5 flex items-center gap-2">
<span className="font-mono text-[11px] text-muted-foreground tabular-nums">
{issue.id}
</span>
{typeof issue.points === "number" && (
<span className="rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground tabular-nums">
{issue.points} pt
</span>
)}
{issue.assignee && (
<span
title={issue.assignee}
className="ml-auto flex size-5 items-center justify-center rounded-full text-[10px] font-medium"
style={{
backgroundColor: `oklch(0.9 0.05 ${hueOf(issue.assignee)})`,
color: `oklch(0.4 0.09 ${hueOf(issue.assignee)})`,
}}
>
{initials(issue.assignee)}
</span>
)}
</div>
</KanbanCard>
);
})}
</KanbanCardList>
</KanbanColumn>
</div>
);
})}
</div>
);
}