Kanban Sprint
Original · freesprint board with progress
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"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 · 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 · {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
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 quoteSimilar components
Browse all →
Kanban Board
Originalkanban board with 3 columns and cards

Kanban Task Card
Originaldetailed task card with labels and avatars

Kanban Column
Originalsingle kanban column with add-card

Kanban Swimlane
Originalswimlane grouped board

Kanban Backlog
Originalbacklog list with priorities

Kanban Compact
Originalcompact mini board

Kanban Labels
Originalboard cards with coloured labels

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

