Kanban Labels
Original · freeboard cards with coloured labels
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-labels.json"use client";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type LabelId =
| "bug"
| "design"
| "backend"
| "research"
| "billing"
| "a11y"
| "docs";
type ColumnId = "triage" | "building" | "review" | "shipped";
type Label = {
id: LabelId;
name: string;
chip: string;
dot: string;
ring: string;
};
type Card = {
id: string;
title: string;
detail: string;
column: ColumnId;
labels: LabelId[];
assignee: string;
points: number;
due: string;
};
const LABELS: Label[] = [
{
id: "bug",
name: "Bug",
chip: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-300",
dot: "bg-rose-500",
ring: "focus-visible:ring-rose-500",
},
{
id: "design",
name: "Design",
chip: "bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-300",
dot: "bg-violet-500",
ring: "focus-visible:ring-violet-500",
},
{
id: "backend",
name: "Backend",
chip: "bg-indigo-100 text-indigo-800 dark:bg-indigo-500/15 dark:text-indigo-300",
dot: "bg-indigo-500",
ring: "focus-visible:ring-indigo-500",
},
{
id: "research",
name: "Research",
chip: "bg-sky-100 text-sky-800 dark:bg-sky-500/15 dark:text-sky-300",
dot: "bg-sky-500",
ring: "focus-visible:ring-sky-500",
},
{
id: "billing",
name: "Billing",
chip: "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-300",
dot: "bg-amber-500",
ring: "focus-visible:ring-amber-500",
},
{
id: "a11y",
name: "Accessibility",
chip: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300",
dot: "bg-emerald-500",
ring: "focus-visible:ring-emerald-500",
},
{
id: "docs",
name: "Docs",
chip: "bg-zinc-200 text-zinc-800 dark:bg-zinc-500/20 dark:text-zinc-300",
dot: "bg-zinc-500",
ring: "focus-visible:ring-zinc-500",
},
];
const LABEL_MAP: Record<LabelId, Label> = LABELS.reduce(
(acc, label) => {
acc[label.id] = label;
return acc;
},
{} as Record<LabelId, Label>,
);
const COLUMNS: { id: ColumnId; name: string; hint: string }[] = [
{ id: "triage", name: "Triage", hint: "Unowned, needs a decision" },
{ id: "building", name: "Building", hint: "Branch open, work in flight" },
{ id: "review", name: "In review", hint: "Waiting on a second pair of eyes" },
{ id: "shipped", name: "Shipped", hint: "Live on production this week" },
];
const INITIAL_CARDS: Card[] = [
{
id: "WI-412",
title: "Checkout retries charge twice on 3DS timeout",
detail:
"Stripe webhook fires before the redirect resolves, so the order row gets inserted a second time.",
column: "triage",
labels: ["bug", "billing", "backend"],
assignee: "Priya N.",
points: 5,
due: "Jul 21",
},
{
id: "WI-398",
title: "Pricing table collapses under 360px",
detail:
"The comparison grid forces a 3-column min-width; Galaxy S8 users get a horizontal scrollbar.",
column: "triage",
labels: ["design", "bug"],
assignee: "Marco L.",
points: 2,
due: "Jul 23",
},
{
id: "WI-377",
title: "Interview 6 agency owners about reporting exports",
detail:
"Four calls booked. Looking for whether CSV beats a live share link for client handoffs.",
column: "triage",
labels: ["research"],
assignee: "Ada K.",
points: 3,
due: "Aug 04",
},
{
id: "WI-401",
title: "Keyboard traps in the invoice date picker",
detail:
"Tab escapes the popover but focus never returns to the trigger. Screen readers announce nothing on close.",
column: "building",
labels: ["a11y", "bug"],
assignee: "Ada K.",
points: 3,
due: "Jul 19",
},
{
id: "WI-405",
title: "Move usage aggregation to a nightly rollup job",
detail:
"Dashboard queries scan 40M event rows per load. Precompute per-workspace daily totals instead.",
column: "building",
labels: ["backend"],
assignee: "Sam O.",
points: 8,
due: "Jul 28",
},
{
id: "WI-388",
title: "Empty states for the new board view",
detail:
"Three variants: no cards, filtered-to-zero, and first-run. Copy is drafted, illustrations pending.",
column: "building",
labels: ["design", "docs"],
assignee: "Marco L.",
points: 2,
due: "Jul 22",
},
{
id: "WI-369",
title: "Proration preview on plan downgrade",
detail:
"Shows the credit amount before the user confirms. Needs a second review on the rounding path.",
column: "review",
labels: ["billing", "backend"],
assignee: "Priya N.",
points: 5,
due: "Jul 18",
},
{
id: "WI-352",
title: "Contrast pass on the dark theme chips",
detail:
"Raised every label token to at least 4.5:1 against the card surface. Verified with axe.",
column: "review",
labels: ["a11y", "design"],
assignee: "Ada K.",
points: 1,
due: "Jul 17",
},
{
id: "WI-340",
title: "Webhook signing key rotation guide",
detail:
"Step-by-step for rotating without dropping events, plus the 24h dual-key window.",
column: "shipped",
labels: ["docs", "backend"],
assignee: "Sam O.",
points: 2,
due: "Jul 11",
},
{
id: "WI-331",
title: "Fix dropped events during deploy restarts",
detail:
"Added a drain phase to the worker so in-flight jobs finish before SIGTERM.",
column: "shipped",
labels: ["bug", "backend"],
assignee: "Sam O.",
points: 5,
due: "Jul 09",
},
];
function CheckIcon() {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2.25}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 8.5 6.25 12 13 4.5" />
</svg>
);
}
function TagIcon() {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7.4 1.8H2.6a.8.8 0 0 0-.8.8v4.8c0 .21.084.416.234.566l6.2 6.2a.8.8 0 0 0 1.132 0l4.8-4.8a.8.8 0 0 0 0-1.132l-6.2-6.2a.8.8 0 0 0-.566-.234Z" />
<circle cx="5" cy="5" r="1" fill="currentColor" stroke="none" />
</svg>
);
}
function ArrowIcon({ dir }: { dir: "left" | "right" }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
style={{ transform: dir === "left" ? "rotate(180deg)" : undefined }}
>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
);
}
export default function KanbanLabels() {
const reduced = useReducedMotion();
const uid = useId();
const [cards, setCards] = useState<Card[]>(INITIAL_CARDS);
const [active, setActive] = useState<LabelId[]>([]);
const [matchAll, setMatchAll] = useState(false);
const liveRef = useRef<HTMLParagraphElement | null>(null);
const toggleLabel = useCallback((id: LabelId) => {
setActive((prev) =>
prev.includes(id) ? prev.filter((l) => l !== id) : [...prev, id],
);
}, []);
const visible = useMemo(() => {
if (active.length === 0) return cards;
return cards.filter((card) =>
matchAll
? active.every((l) => card.labels.includes(l))
: active.some((l) => card.labels.includes(l)),
);
}, [cards, active, matchAll]);
const counts = useMemo(() => {
const map = {} as Record<LabelId, number>;
for (const label of LABELS) map[label.id] = 0;
for (const card of cards) for (const l of card.labels) map[l] += 1;
return map;
}, [cards]);
const move = useCallback((cardId: string, dir: -1 | 1) => {
setCards((prev) =>
prev.map((card) => {
if (card.id !== cardId) return card;
const i = COLUMNS.findIndex((c) => c.id === card.column);
const next = Math.min(COLUMNS.length - 1, Math.max(0, i + dir));
if (next === i) return card;
const target = COLUMNS[next];
if (liveRef.current) {
liveRef.current.textContent = `${card.id} moved to ${target.name}.`;
}
return { ...card, column: target.id };
}),
);
}, []);
const ease = [0.22, 1, 0.36, 1] as const;
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 lg:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes wikl-sheen {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes wikl-pulse {
0%, 100% { opacity: 0.45; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.wikl-sheen::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 35%, rgba(255,255,255,0.5) 50%, transparent 65%);
transform: translateX(-120%);
pointer-events: none;
}
.wikl-sheen:hover::after {
animation: wikl-sheen 900ms ease-out;
}
.wikl-live-dot {
animation: wikl-pulse 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.wikl-sheen:hover::after,
.wikl-live-dot {
animation: none !important;
}
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.10),transparent_70%)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(129,140,248,0.14),transparent_70%)]"
/>
<div className="relative mx-auto w-full max-w-6xl">
<header className="mb-8 flex flex-col gap-4 sm:mb-10">
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span className="wikl-live-dot h-1.5 w-1.5 rounded-full bg-emerald-500" />
Sprint 24 · week 2 of 2
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<h2 className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
Billing platform board
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Filter by label to see who is blocked on what. Cards move with
the arrow buttons or the arrow keys once a card is focused.
</p>
</div>
<dl className="flex shrink-0 gap-6">
<div>
<dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
Showing
</dt>
<dd className="text-xl font-semibold tabular-nums">
{visible.length}
<span className="text-sm font-normal text-slate-500 dark:text-slate-400">
/{cards.length}
</span>
</dd>
</div>
<div>
<dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
Points
</dt>
<dd className="text-xl font-semibold tabular-nums">
{visible.reduce((sum, c) => sum + c.points, 0)}
</dd>
</div>
</dl>
</div>
</header>
<div className="mb-8 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-wrap items-center gap-3">
<span
id={`${uid}-filter-label`}
className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
<TagIcon />
Labels
</span>
<div
role="group"
aria-labelledby={`${uid}-filter-label`}
className="flex flex-wrap items-center gap-2"
>
{LABELS.map((label) => {
const on = active.includes(label.id);
return (
<button
key={label.id}
type="button"
aria-pressed={on}
onClick={() => toggleLabel(label.id)}
className={[
"group inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
label.ring,
on
? `border-transparent ${label.chip}`
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-600 dark:hover:bg-slate-800",
].join(" ")}
>
<span
aria-hidden="true"
className={`h-2 w-2 rounded-full ${label.dot} ${on ? "" : "opacity-70"}`}
/>
{label.name}
<span className="tabular-nums opacity-60">
{counts[label.id]}
</span>
{on ? (
<span aria-hidden="true" className="-mr-0.5">
<CheckIcon />
</span>
) : null}
</button>
);
})}
</div>
<div className="ml-auto flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={matchAll}
onClick={() => setMatchAll((v) => !v)}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<span
aria-hidden="true"
className={`relative h-4 w-7 rounded-full transition-colors ${
matchAll
? "bg-indigo-600"
: "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${
matchAll ? "left-3.5" : "left-0.5"
}`}
/>
</span>
Match all
</button>
<button
type="button"
onClick={() => setActive([])}
disabled={active.length === 0}
className="rounded-full px-2.5 py-1 text-xs font-medium text-slate-500 transition hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-slate-500 dark:text-slate-400 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
Clear
</button>
</div>
</div>
</div>
<p ref={liveRef} aria-live="polite" className="sr-only" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{COLUMNS.map((column, colIndex) => {
const columnCards = visible.filter((c) => c.column === column.id);
return (
<section
key={column.id}
aria-labelledby={`${uid}-col-${column.id}`}
className="flex flex-col rounded-2xl border border-slate-200 bg-slate-100/60 p-3 dark:border-slate-800 dark:bg-slate-900/50"
>
<div className="mb-3 flex items-baseline justify-between gap-2 px-1">
<h3
id={`${uid}-col-${column.id}`}
className="text-sm font-semibold tracking-tight"
>
{column.name}
</h3>
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium tabular-nums text-slate-600 dark:bg-slate-800 dark:text-slate-400">
{columnCards.length}
</span>
</div>
<p className="mb-3 px-1 text-xs text-slate-500 dark:text-slate-500">
{column.hint}
</p>
<ul className="flex flex-1 flex-col gap-3">
<AnimatePresence initial={false} mode="popLayout">
{columnCards.map((card) => (
<motion.li
key={card.id}
layout={!reduced}
initial={
reduced ? false : { opacity: 0, y: 8, scale: 0.98 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.96, y: -4 }
}
transition={
reduced
? { duration: 0 }
: { duration: 0.28, ease }
}
>
<article
tabIndex={0}
aria-label={`${card.id}: ${card.title}. Column ${column.name}. Labels: ${card.labels
.map((l) => LABEL_MAP[l].name)
.join(", ")}.`}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
move(card.id, 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(card.id, -1);
}
}}
className="wikl-sheen group relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition hover:border-slate-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:focus-visible:ring-offset-slate-950"
>
<div className="flex flex-wrap gap-1.5">
{card.labels.map((id) => {
const label = LABEL_MAP[id];
const dimmed =
active.length > 0 && !active.includes(id);
return (
<span
key={id}
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium leading-4 transition-opacity ${label.chip} ${
dimmed ? "opacity-45" : "opacity-100"
}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${label.dot}`}
/>
{label.name}
</span>
);
})}
</div>
<h4 className="mt-2.5 text-pretty text-sm font-medium leading-snug">
{card.title}
</h4>
<p className="mt-1.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{card.detail}
</p>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-100 pt-2.5 dark:border-slate-800">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-200 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{card.assignee.charAt(0)}
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{card.id} · {card.points} pts · {card.due}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => move(card.id, -1)}
disabled={colIndex === 0}
aria-label={`Move ${card.id} to ${
COLUMNS[Math.max(0, colIndex - 1)].name
}`}
className="grid h-6 w-6 place-items-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<ArrowIcon dir="left" />
</button>
<button
type="button"
onClick={() => move(card.id, 1)}
disabled={colIndex === COLUMNS.length - 1}
aria-label={`Move ${card.id} to ${
COLUMNS[
Math.min(COLUMNS.length - 1, colIndex + 1)
].name
}`}
className="grid h-6 w-6 place-items-center rounded-md text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<ArrowIcon dir="right" />
</button>
</div>
</div>
</article>
</motion.li>
))}
</AnimatePresence>
{columnCards.length === 0 ? (
<li className="grid flex-1 place-items-center rounded-xl border border-dashed border-slate-300 px-3 py-8 text-center text-xs text-slate-500 dark:border-slate-700 dark:text-slate-500">
{active.length > 0
? "No cards match this label filter."
: "Nothing here yet."}
</li>
) : null}
</ul>
</section>
);
})}
</div>
</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 Sprint
Originalsprint board with progress

Kanban Compact
Originalcompact mini board

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.

