Web InnoventixFreeCode

Kanban Sprint

Original · free

sprint board with progress

byWeb InnoventixReact + Tailwind
kanbansprint
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/kanban-sprint.json
kanban-sprint.tsx
"use client";

import { useCallback, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type ColumnId = "backlog" | "in-progress" | "review" | "done";

type Priority = "high" | "medium" | "low";

type Ticket = {
  id: string;
  key: string;
  title: string;
  assignee: string;
  initials: string;
  points: number;
  priority: Priority;
  column: ColumnId;
};

type Column = {
  id: ColumnId;
  label: string;
  hint: string;
  limit: number | null;
};

const COLUMNS: Column[] = [
  { id: "backlog", label: "Backlog", hint: "Groomed and estimated", limit: null },
  { id: "in-progress", label: "In progress", hint: "WIP limit 3", limit: 3 },
  { id: "review", label: "In review", hint: "Awaiting code review", limit: 2 },
  { id: "done", label: "Done", hint: "Merged to main", limit: null },
];

const INITIAL_TICKETS: Ticket[] = [
  {
    id: "t-1",
    key: "PAY-412",
    title: "Retry failed Stripe webhooks with exponential backoff",
    assignee: "Nadia Rehman",
    initials: "NR",
    points: 5,
    priority: "high",
    column: "backlog",
  },
  {
    id: "t-2",
    key: "PAY-418",
    title: "Split checkout bundle so the pay button hydrates first",
    assignee: "Tomas Vidal",
    initials: "TV",
    points: 3,
    priority: "medium",
    column: "backlog",
  },
  {
    id: "t-3",
    key: "INF-207",
    title: "Move nightly invoice export off the primary replica",
    assignee: "Grace Okonkwo",
    initials: "GO",
    points: 8,
    priority: "low",
    column: "backlog",
  },
  {
    id: "t-4",
    key: "PAY-403",
    title: "3-D Secure challenge loops on Safari 17.4",
    assignee: "Nadia Rehman",
    initials: "NR",
    points: 5,
    priority: "high",
    column: "in-progress",
  },
  {
    id: "t-5",
    key: "DSH-119",
    title: "Refund timeline shows UTC instead of merchant timezone",
    assignee: "Ivan Petrov",
    initials: "IP",
    points: 2,
    priority: "medium",
    column: "in-progress",
  },
  {
    id: "t-6",
    key: "DSH-115",
    title: "Keyboard trap in the payout filter drawer",
    assignee: "Mira Chandra",
    initials: "MC",
    points: 3,
    priority: "high",
    column: "review",
  },
  {
    id: "t-7",
    key: "INF-198",
    title: "Pin Postgres 16.2 in the staging compose file",
    assignee: "Grace Okonkwo",
    initials: "GO",
    points: 1,
    priority: "low",
    column: "review",
  },
  {
    id: "t-8",
    key: "PAY-397",
    title: "Idempotency keys on the charge endpoint",
    assignee: "Tomas Vidal",
    initials: "TV",
    points: 5,
    priority: "high",
    column: "done",
  },
  {
    id: "t-9",
    key: "DSH-108",
    title: "Empty state for merchants with zero disputes",
    assignee: "Mira Chandra",
    initials: "MC",
    points: 2,
    priority: "low",
    column: "done",
  },
  {
    id: "t-10",
    key: "INF-190",
    title: "Alert when the webhook queue depth passes 500",
    assignee: "Ivan Petrov",
    initials: "IP",
    points: 3,
    priority: "medium",
    column: "done",
  },
];

const PRIORITY_STYLES: Record<Priority, string> = {
  high: "bg-rose-100 text-rose-700 ring-rose-600/20 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/25",
  medium:
    "bg-amber-100 text-amber-800 ring-amber-600/20 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/25",
  low: "bg-sky-100 text-sky-700 ring-sky-600/20 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/25",
};

const PRIORITY_LABEL: Record<Priority, string> = {
  high: "High priority",
  medium: "Medium priority",
  low: "Low priority",
};

const AVATAR_STYLES: Record<string, string> = {
  NR: "bg-violet-600 text-white dark:bg-violet-500",
  TV: "bg-emerald-600 text-white dark:bg-emerald-500",
  GO: "bg-sky-600 text-white dark:bg-sky-500",
  IP: "bg-amber-600 text-white dark:bg-amber-500",
  MC: "bg-rose-600 text-white dark:bg-rose-500",
};

function columnIndex(id: ColumnId): number {
  return COLUMNS.findIndex((c) => c.id === id);
}

function ArrowIcon({ direction }: { direction: "left" | "right" }) {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      focusable="false"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {direction === "left" ? (
        <>
          <path d="M10 3.5 5.5 8l4.5 4.5" />
          <path d="M5.75 8h5" />
        </>
      ) : (
        <>
          <path d="M6 3.5 10.5 8 6 12.5" />
          <path d="M10.25 8h-5" />
        </>
      )}
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      focusable="false"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M3.5 8.5l3 3 6-7" />
    </svg>
  );
}

function FlameIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      focusable="false"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M8 1.75s3.75 2.9 3.75 6.35a3.75 3.75 0 1 1-7.5 0c0-1.2.55-2.2 1.1-2.95.3.8.95 1.35 1.6 1.35C7.55 6.5 6.9 4.1 8 1.75Z" />
    </svg>
  );
}

export default function KanbanSprint() {
  const reduceMotion = useReducedMotion();
  const [tickets, setTickets] = useState<Ticket[]>(INITIAL_TICKETS);
  const [announcement, setAnnouncement] = useState<string>("");
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});

  const move = useCallback((ticketId: string, delta: -1 | 1) => {
    setTickets((prev) => {
      const target = prev.find((t) => t.id === ticketId);
      if (!target) return prev;
      const next = columnIndex(target.column) + delta;
      if (next < 0 || next >= COLUMNS.length) return prev;
      const destination = COLUMNS[next];
      const occupancy = prev.filter((t) => t.column === destination.id).length;
      if (destination.limit !== null && occupancy >= destination.limit) {
        setAnnouncement(
          `${target.key} not moved. ${destination.label} is at its WIP limit of ${destination.limit}.`,
        );
        return prev;
      }
      setAnnouncement(`${target.key} moved to ${destination.label}.`);
      return prev.map((t) => (t.id === ticketId ? { ...t, column: destination.id } : t));
    });
  }, []);

  const stats = useMemo(() => {
    const total = tickets.reduce((sum, t) => sum + t.points, 0);
    const done = tickets
      .filter((t) => t.column === "done")
      .reduce((sum, t) => sum + t.points, 0);
    const active = tickets
      .filter((t) => t.column === "in-progress" || t.column === "review")
      .reduce((sum, t) => sum + t.points, 0);
    const pct = total === 0 ? 0 : Math.round((done / total) * 100);
    const activePct = total === 0 ? 0 : Math.round((active / total) * 100);
    return { total, done, active, pct, activePct, remaining: total - done };
  }, [tickets]);

  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>, ticketId: string) => {
      if (event.key === "ArrowLeft") {
        event.preventDefault();
        move(ticketId, -1);
        window.requestAnimationFrame(() => cardRefs.current[ticketId]?.focus());
      } else if (event.key === "ArrowRight") {
        event.preventDefault();
        move(ticketId, 1);
        window.requestAnimationFrame(() => cardRefs.current[ticketId]?.focus());
      }
    },
    [move],
  );

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes ksprint${uid}-sheen {
          0% { transform: translateX(-110%); }
          100% { transform: translateX(360%); }
        }
        @keyframes ksprint${uid}-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.45; transform: scale(0.72); }
        }
        .ksprint${uid}-sheen {
          animation: ksprint${uid}-sheen 2.8s ease-in-out infinite;
        }
        .ksprint${uid}-pulse {
          animation: ksprint${uid}-pulse 2s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .ksprint${uid}-sheen,
          .ksprint${uid}-pulse {
            animation: none !important;
          }
        }
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        <header className="flex flex-col gap-6 border-b border-slate-200 pb-8 sm:flex-row sm:items-end sm:justify-between dark:border-slate-800">
          <div>
            <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
              <span
                className={`ksprint${uid}-pulse inline-block h-1.5 w-1.5 rounded-full bg-indigo-500`}
                aria-hidden="true"
              />
              Sprint 24 &middot; Payments platform
            </div>
            <h2 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
              Checkout reliability push
            </h2>
            <p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Two weeks to cut webhook failures below 0.1%. Move a card with the arrow buttons, or
              focus a card and press the left and right arrow keys.
            </p>
          </div>

          <dl className="grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 text-center dark:border-slate-800 dark:bg-slate-800">
            {[
              { label: "Committed", value: `${stats.total} pts` },
              { label: "Shipped", value: `${stats.done} pts` },
              { label: "Days left", value: "4" },
            ].map((item) => (
              <div key={item.label} className="bg-white px-4 py-3 dark:bg-slate-900">
                <dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                  {item.label}
                </dt>
                <dd className="mt-1 text-lg font-semibold tabular-nums">{item.value}</dd>
              </div>
            ))}
          </dl>
        </header>

        <div className="mt-8 rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex flex-wrap items-baseline justify-between gap-2">
            <h3 className="text-sm font-semibold">Sprint progress</h3>
            <p className="text-sm tabular-nums text-slate-600 dark:text-slate-400">
              <span className="font-semibold text-slate-900 dark:text-slate-100">{stats.pct}%</span>{" "}
              complete &middot; {stats.remaining} pts remaining
            </p>
          </div>

          <div
            role="progressbar"
            aria-valuenow={stats.pct}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-label="Sprint completion by story points"
            className="relative mt-3 h-3 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
          >
            <motion.div
              className="absolute inset-y-0 left-0 rounded-full bg-indigo-500/25 dark:bg-indigo-400/20"
              initial={false}
              animate={{ width: `${Math.min(100, stats.pct + stats.activePct)}%` }}
              transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 26 }}
            />
            <motion.div
              className="absolute inset-y-0 left-0 overflow-hidden rounded-full bg-emerald-500 dark:bg-emerald-400"
              initial={false}
              animate={{ width: `${stats.pct}%` }}
              transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 26 }}
            >
              <span
                className={`ksprint${uid}-sheen absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-transparent via-white/55 to-transparent`}
                aria-hidden="true"
              />
            </motion.div>
          </div>

          <ul className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
            <li className="flex items-center gap-1.5">
              <span
                className="h-2 w-2 rounded-full bg-emerald-500 dark:bg-emerald-400"
                aria-hidden="true"
              />
              Done {stats.done} pts
            </li>
            <li className="flex items-center gap-1.5">
              <span
                className="h-2 w-2 rounded-full bg-indigo-500/40 dark:bg-indigo-400/40"
                aria-hidden="true"
              />
              In flight {stats.active} pts
            </li>
            <li className="flex items-center gap-1.5">
              <span
                className="h-2 w-2 rounded-full bg-slate-300 dark:bg-slate-700"
                aria-hidden="true"
              />
              Not started {stats.total - stats.done - stats.active} pts
            </li>
          </ul>
        </div>

        <div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
          {COLUMNS.map((column) => {
            const items = tickets.filter((t) => t.column === column.id);
            const points = items.reduce((sum, t) => sum + t.points, 0);
            const overLimit = column.limit !== null && items.length >= column.limit;

            return (
              <div
                key={column.id}
                className="flex flex-col rounded-xl border border-slate-200 bg-slate-100/70 p-3 dark:border-slate-800 dark:bg-slate-900/60"
              >
                <div className="flex items-start justify-between gap-2 px-1 pb-3">
                  <div>
                    <h3 className="flex items-center gap-2 text-sm font-semibold">
                      {column.label}
                      <span className="rounded-full bg-slate-200 px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-300">
                        {items.length}
                      </span>
                    </h3>
                    <p
                      className={`mt-0.5 text-[11px] ${
                        overLimit
                          ? "font-medium text-rose-600 dark:text-rose-400"
                          : "text-slate-500 dark:text-slate-400"
                      }`}
                    >
                      {overLimit ? `At WIP limit (${column.limit})` : column.hint}
                    </p>
                  </div>
                  <span className="shrink-0 pt-0.5 text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
                    {points} pts
                  </span>
                </div>

                <ul className="flex flex-1 flex-col gap-2.5">
                  <AnimatePresence initial={false}>
                    {items.map((ticket) => {
                      const idx = columnIndex(ticket.column);
                      const prevCol = idx > 0 ? COLUMNS[idx - 1] : null;
                      const nextCol = idx < COLUMNS.length - 1 ? COLUMNS[idx + 1] : null;
                      const nextBlocked =
                        nextCol !== null &&
                        nextCol.limit !== null &&
                        tickets.filter((t) => t.column === nextCol.id).length >= nextCol.limit;

                      return (
                        <motion.li
                          key={ticket.id}
                          layout={!reduceMotion}
                          initial={reduceMotion ? false : { opacity: 0, y: 8 }}
                          animate={{ opacity: 1, y: 0 }}
                          exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
                          transition={
                            reduceMotion
                              ? { duration: 0 }
                              : { type: "spring", stiffness: 420, damping: 34 }
                          }
                        >
                          <div
                            ref={(node) => {
                              cardRefs.current[ticket.id] = node;
                            }}
                            tabIndex={0}
                            role="group"
                            aria-label={`${ticket.key}: ${ticket.title}. ${ticket.points} points, ${PRIORITY_LABEL[ticket.priority]}, assigned to ${ticket.assignee}. Column ${column.label}. Use left and right arrow keys to move.`}
                            onKeyDown={(event) => handleKeyDown(event, ticket.id)}
                            className="group rounded-lg border border-slate-200 bg-white p-3 shadow-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 hover:border-slate-300 dark:border-slate-800 dark:bg-slate-950 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900 dark:hover:border-slate-700"
                          >
                            <div className="flex items-center justify-between gap-2">
                              <span className="font-mono text-[11px] font-medium text-slate-500 dark:text-slate-400">
                                {ticket.key}
                              </span>
                              <span
                                className={`inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset ${PRIORITY_STYLES[ticket.priority]}`}
                              >
                                {ticket.priority === "high" ? <FlameIcon /> : null}
                                {ticket.priority}
                                <span className="sr-only"> priority</span>
                              </span>
                            </div>

                            <p className="mt-2 text-sm font-medium leading-snug text-slate-800 dark:text-slate-100">
                              {ticket.title}
                            </p>

                            <div className="mt-3 flex items-center justify-between gap-2">
                              <div className="flex items-center gap-2">
                                <span
                                  aria-hidden="true"
                                  className={`flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold ${
                                    AVATAR_STYLES[ticket.initials] ?? "bg-slate-600 text-white"
                                  }`}
                                >
                                  {ticket.initials}
                                </span>
                                <span className="text-xs text-slate-600 dark:text-slate-400">
                                  {ticket.assignee.split(" ")[0]}
                                </span>
                                <span className="rounded border border-slate-200 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-600 dark:border-slate-700 dark:text-slate-400">
                                  {ticket.points}
                                  <span className="sr-only"> story points</span>
                                </span>
                              </div>

                              <div className="flex items-center gap-1">
                                <button
                                  type="button"
                                  onClick={() => move(ticket.id, -1)}
                                  disabled={prevCol === null}
                                  aria-label={
                                    prevCol
                                      ? `Move ${ticket.key} back to ${prevCol.label}`
                                      : `${ticket.key} is in the first column`
                                  }
                                  className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-35 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400"
                                >
                                  <ArrowIcon direction="left" />
                                </button>
                                <button
                                  type="button"
                                  onClick={() => move(ticket.id, 1)}
                                  disabled={nextCol === null || nextBlocked}
                                  aria-label={
                                    nextCol === null
                                      ? `${ticket.key} is already done`
                                      : nextBlocked
                                        ? `${nextCol.label} is at its WIP limit`
                                        : `Move ${ticket.key} forward to ${nextCol.label}`
                                  }
                                  className="flex h-7 w-7 items-center justify-center rounded-md border border-slate-200 text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-35 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400"
                                >
                                  {ticket.column === "done" ? (
                                    <CheckIcon />
                                  ) : (
                                    <ArrowIcon direction="right" />
                                  )}
                                </button>
                              </div>
                            </div>
                          </div>
                        </motion.li>
                      );
                    })}
                  </AnimatePresence>

                  {items.length === 0 ? (
                    <li className="rounded-lg border border-dashed border-slate-300 px-3 py-6 text-center text-xs text-slate-500 dark:border-slate-700 dark:text-slate-500">
                      Nothing here yet
                    </li>
                  ) : null}
                </ul>
              </div>
            );
          })}
        </div>

        <p aria-live="polite" role="status" className="sr-only">
          {announcement}
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →