SaaS & Ops

Sprint Board

A kanban sprint board of drag-reorderable issue cards across columns, each with a work-in-progress count that flags over-limit columns.

Install

npx shadcn@latest add @paragon/sprint-board

Also installs: kanban-column

sprint-board.tsx

"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>
  );
}