Kanban Compact
Original · freecompact mini board
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-compact.json"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type LaneId = "triage" | "doing" | "review" | "shipped";
type Priority = "high" | "med" | "low";
type Density = "compact" | "cozy";
type Board = Record<LaneId, string[]>;
type Axis = "up" | "down" | "left" | "right";
type Person = {
id: string;
initials: string;
name: string;
chip: string;
};
type Lane = {
id: LaneId;
name: string;
limit: number | null;
dot: string;
};
type Card = {
id: string;
ref: string;
title: string;
priority: Priority;
owner: string;
tag: string;
done: number;
total: number;
};
const PEOPLE: Person[] = [
{ id: "rk", initials: "RK", name: "Rina Kovacs", chip: "bg-indigo-500" },
{ id: "tm", initials: "TM", name: "Theo Mbeki", chip: "bg-emerald-500" },
{ id: "jo", initials: "JO", name: "Joan Okafor", chip: "bg-violet-500" },
{ id: "ls", initials: "LS", name: "Lucia Serrano", chip: "bg-amber-500" },
];
const PERSON_BY_ID: Record<string, Person> = PEOPLE.reduce<Record<string, Person>>(
(acc, person) => {
acc[person.id] = person;
return acc;
},
{},
);
const LANES: Lane[] = [
{ id: "triage", name: "Triage", limit: null, dot: "bg-slate-400 dark:bg-slate-500" },
{ id: "doing", name: "Doing", limit: 3, dot: "bg-indigo-500" },
{ id: "review", name: "Review", limit: 2, dot: "bg-violet-500" },
{ id: "shipped", name: "Shipped", limit: null, dot: "bg-emerald-500" },
];
const LANE_BY_ID: Record<LaneId, Lane> = LANES.reduce<Record<LaneId, Lane>>(
(acc, lane) => {
acc[lane.id] = lane;
return acc;
},
{} as Record<LaneId, Lane>,
);
const CARDS: Record<string, Card> = {
"ds-412": {
id: "ds-412",
ref: "DS-412",
title: "Focus ring is invisible on slate-900 surfaces",
priority: "high",
owner: "jo",
tag: "a11y",
done: 0,
total: 3,
},
"ds-409": {
id: "ds-409",
ref: "DS-409",
title: "Deprecate legacy Button size=\"xs\"",
priority: "low",
owner: "tm",
tag: "cleanup",
done: 1,
total: 4,
},
"ds-407": {
id: "ds-407",
ref: "DS-407",
title: "Icon exports missing sideEffects: false",
priority: "med",
owner: "ls",
tag: "build",
done: 0,
total: 2,
},
"ds-398": {
id: "ds-398",
ref: "DS-398",
title: "Combobox: async filtering and empty state",
priority: "high",
owner: "rk",
tag: "component",
done: 3,
total: 6,
},
"ds-395": {
id: "ds-395",
ref: "DS-395",
title: "Move spacing scale onto a 4pt grid",
priority: "med",
owner: "tm",
tag: "tokens",
done: 5,
total: 9,
},
"ds-390": {
id: "ds-390",
ref: "DS-390",
title: "Toast stacking order regresses under 380px",
priority: "high",
owner: "jo",
tag: "bug",
done: 4,
total: 4,
},
"ds-386": {
id: "ds-386",
ref: "DS-386",
title: "Docs: write the Combobox usage page",
priority: "low",
owner: "ls",
tag: "docs",
done: 2,
total: 3,
},
"ds-381": {
id: "ds-381",
ref: "DS-381",
title: "Table: sticky header + column resize",
priority: "med",
owner: "rk",
tag: "component",
done: 7,
total: 7,
},
"ds-377": {
id: "ds-377",
ref: "DS-377",
title: "Publish the v2.3 changelog",
priority: "low",
owner: "ls",
tag: "release",
done: 2,
total: 2,
},
};
const INITIAL_BOARD: Board = {
triage: ["ds-412", "ds-409", "ds-407"],
doing: ["ds-398", "ds-395"],
review: ["ds-390", "ds-386"],
shipped: ["ds-381", "ds-377"],
};
const PRIORITY_DOT: Record<Priority, string> = {
high: "bg-rose-500",
med: "bg-amber-500",
low: "bg-sky-500",
};
const PRIORITY_LABEL: Record<Priority, string> = {
high: "High priority",
med: "Medium priority",
low: "Low priority",
};
function cloneBoard(board: Board): Board {
return {
triage: [...board.triage],
doing: [...board.doing],
review: [...board.review],
shipped: [...board.shipped],
};
}
export default function KanbanCompact() {
const reduce = useReducedMotion();
const [board, setBoard] = useState<Board>(() => cloneBoard(INITIAL_BOARD));
const [density, setDensity] = useState<Density>("compact");
const [filter, setFilter] = useState<string>("all");
const [focusId, setFocusId] = useState<string>("ds-412");
const [grabbedId, setGrabbedId] = useState<string | null>(null);
const [message, setMessage] = useState<string>("");
const cardRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const wantFocus = useRef<string | null>(null);
const snapshot = useRef<Board | null>(null);
useEffect(() => {
const id = wantFocus.current;
if (!id) return;
wantFocus.current = null;
cardRefs.current[id]?.focus();
});
const isVisible = useCallback(
(id: string) => filter === "all" || CARDS[id].owner === filter,
[filter],
);
const visibleBoard = useMemo<Board>(
() => ({
triage: board.triage.filter(isVisible),
doing: board.doing.filter(isVisible),
review: board.review.filter(isVisible),
shipped: board.shipped.filter(isVisible),
}),
[board, isVisible],
);
const flatVisible = useMemo(
() => LANES.flatMap((lane) => visibleBoard[lane.id]),
[visibleBoard],
);
const activeId = flatVisible.includes(focusId) ? focusId : flatVisible[0];
const locate = useCallback(
(id: string): { lane: LaneId; index: number } | null => {
for (const lane of LANES) {
const index = visibleBoard[lane.id].indexOf(id);
if (index !== -1) return { lane: lane.id, index };
}
return null;
},
[visibleBoard],
);
const announce = useCallback((text: string) => setMessage(text), []);
const moveFocus = useCallback(
(id: string, axis: Axis) => {
const at = locate(id);
if (!at) return;
const laneIndex = LANES.findIndex((lane) => lane.id === at.lane);
if (axis === "up" || axis === "down") {
const column = visibleBoard[at.lane];
const next = column[at.index + (axis === "up" ? -1 : 1)];
if (!next) return;
setFocusId(next);
wantFocus.current = next;
return;
}
const step = axis === "left" ? -1 : 1;
for (let i = laneIndex + step; i >= 0 && i < LANES.length; i += step) {
const column = visibleBoard[LANES[i].id];
if (column.length === 0) continue;
const next = column[Math.min(at.index, column.length - 1)];
setFocusId(next);
wantFocus.current = next;
return;
}
},
[locate, visibleBoard],
);
const moveCard = useCallback(
(id: string, axis: Axis) => {
const at = locate(id);
if (!at) return;
if (axis === "up" || axis === "down") {
const column = visibleBoard[at.lane];
const partner = column[at.index + (axis === "up" ? -1 : 1)];
if (!partner) return;
setBoard((prev) => {
const next = cloneBoard(prev);
const full = next[at.lane];
const a = full.indexOf(id);
const b = full.indexOf(partner);
full[a] = partner;
full[b] = id;
return next;
});
announce(
`${CARDS[id].ref} moved ${axis} to position ${at.index + (axis === "up" ? 0 : 2)} of ${column.length} in ${LANE_BY_ID[at.lane].name}.`,
);
wantFocus.current = id;
return;
}
const laneIndex = LANES.findIndex((lane) => lane.id === at.lane);
const targetIndex = laneIndex + (axis === "left" ? -1 : 1);
if (targetIndex < 0 || targetIndex >= LANES.length) return;
const targetLane = LANES[targetIndex].id;
setBoard((prev) => {
const next = cloneBoard(prev);
next[at.lane] = next[at.lane].filter((cardId) => cardId !== id);
const targetVisible = next[targetLane].filter(isVisible);
const anchor = targetVisible[Math.min(at.index, targetVisible.length)];
const insertAt =
anchor === undefined ? next[targetLane].length : next[targetLane].indexOf(anchor);
next[targetLane].splice(insertAt, 0, id);
return next;
});
announce(`${CARDS[id].ref} moved to ${LANE_BY_ID[targetLane].name}.`);
wantFocus.current = id;
},
[announce, isVisible, locate, visibleBoard],
);
const grab = useCallback(
(id: string) => {
snapshot.current = cloneBoard(board);
setGrabbedId(id);
announce(
`${CARDS[id].ref} picked up. Use the arrow keys to move it, Enter to drop, Escape to cancel.`,
);
},
[announce, board],
);
const drop = useCallback(
(id: string) => {
const at = locate(id);
snapshot.current = null;
setGrabbedId(null);
if (at) announce(`${CARDS[id].ref} dropped in ${LANE_BY_ID[at.lane].name}.`);
},
[announce, locate],
);
const cancel = useCallback(
(id: string) => {
if (snapshot.current) setBoard(snapshot.current);
snapshot.current = null;
setGrabbedId(null);
wantFocus.current = id;
announce(`Move cancelled. ${CARDS[id].ref} returned to its original position.`);
},
[announce],
);
const onCardKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {
const axis: Axis | null =
event.key === "ArrowUp"
? "up"
: event.key === "ArrowDown"
? "down"
: event.key === "ArrowLeft"
? "left"
: event.key === "ArrowRight"
? "right"
: null;
if (axis) {
event.preventDefault();
if (grabbedId === id) moveCard(id, axis);
else moveFocus(id, axis);
return;
}
if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
event.preventDefault();
if (grabbedId === id) drop(id);
else grab(id);
return;
}
if (event.key === "Escape" && grabbedId === id) {
event.preventDefault();
cancel(id);
}
},
[cancel, drop, grab, grabbedId, moveCard, moveFocus],
);
const shift = useCallback(
(id: string, axis: "left" | "right") => {
moveCard(id, axis);
setFocusId(id);
},
[moveCard],
);
const reset = useCallback(() => {
setBoard(cloneBoard(INITIAL_BOARD));
setGrabbedId(null);
snapshot.current = null;
announce("Board reset to the start of the sprint.");
}, [announce]);
const totalVisible = flatVisible.length;
const shippedCount = visibleBoard.shipped.length;
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
<style>{`
@keyframes kbcx-lift {
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.45); }
50% { box-shadow: 0 0 0 5px rgba(99, 102, 241, 0); }
}
@keyframes kbcx-slide-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.kbcx-grabbed { animation: kbcx-lift 1.6s ease-out infinite; }
.kbcx-lane { animation: kbcx-slide-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.kbcx-grabbed, .kbcx-lane { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
Sprint 24 · Design Systems
</p>
<h2 className="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Mini board
</h2>
<p className="mt-1.5 max-w-lg text-sm text-slate-600 dark:text-slate-400">
Nine open issues, four lanes, no ceremony. Pick a card up with{" "}
<kbd className="rounded border border-slate-300 bg-white px-1 font-sans text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
Enter
</kbd>{" "}
and steer it with the arrow keys.
</p>
</div>
<div className="flex items-center gap-2">
<div
className="flex rounded-lg border border-slate-200 bg-white p-0.5 dark:border-slate-800 dark:bg-slate-900"
role="group"
aria-label="Card density"
>
{(["compact", "cozy"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setDensity(mode)}
aria-pressed={density === mode}
className={`rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
density === mode
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{mode}
</button>
))}
</div>
<button
type="button"
onClick={reset}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2v3h-3" />
</svg>
Reset
</button>
</div>
</header>
<div className="mt-6 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-slate-500">
Assignee
</span>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Filter by assignee">
<button
type="button"
onClick={() => setFilter("all")}
aria-pressed={filter === "all"}
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
filter === "all"
? "border-indigo-500 bg-indigo-500 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-700"
}`}
>
Everyone
</button>
{PEOPLE.map((person) => (
<button
key={person.id}
type="button"
onClick={() => setFilter(person.id)}
aria-pressed={filter === person.id}
className={`inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
filter === person.id
? "border-indigo-500 bg-indigo-500 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-slate-700"
}`}
>
<span
className={`grid h-4 w-4 place-items-center rounded-full text-[8px] font-bold text-white ${person.chip}`}
aria-hidden="true"
>
{person.initials}
</span>
{person.name.split(" ")[0]}
</button>
))}
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{LANES.map((lane, laneIndex) => {
const ids = visibleBoard[lane.id];
const overLimit = lane.limit !== null && ids.length > lane.limit;
return (
<div
key={lane.id}
className="kbcx-lane flex flex-col rounded-xl border border-slate-200 bg-white/70 p-2 dark:border-slate-800 dark:bg-slate-900/50"
style={{ animationDelay: `${laneIndex * 60}ms` }}
>
<div className="flex items-center justify-between px-1 pb-2 pt-0.5">
<div className="flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${lane.dot}`} aria-hidden="true" />
<h3
id={`kbcx-lane-${lane.id}`}
className="text-xs font-semibold text-slate-800 dark:text-slate-200"
>
{lane.name}
</h3>
</div>
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums ${
overLimit
? "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
}`}
>
{lane.limit !== null ? `${ids.length}/${lane.limit}` : ids.length}
{overLimit ? (
<span className="sr-only"> — over the work-in-progress limit</span>
) : null}
</span>
</div>
<ul
aria-labelledby={`kbcx-lane-${lane.id}`}
className="flex min-h-[3.5rem] flex-col gap-1.5"
>
{ids.map((id) => {
const card = CARDS[id];
const owner = PERSON_BY_ID[card.owner];
const grabbed = grabbedId === id;
const complete = card.done === card.total;
return (
<motion.li
key={id}
layout={reduce ? false : true}
transition={{ type: "spring", stiffness: 520, damping: 42 }}
className="group relative"
>
<button
type="button"
ref={(node) => {
cardRefs.current[id] = node;
}}
tabIndex={activeId === id ? 0 : -1}
aria-pressed={grabbed}
aria-describedby="kbcx-help"
onFocus={() => setFocusId(id)}
onClick={() => (grabbed ? drop(id) : grab(id))}
onKeyDown={(event) => onCardKeyDown(event, id)}
className={`w-full rounded-lg border bg-white text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 ${
density === "cozy" ? "p-2.5" : "px-2 py-1.5"
} ${
grabbed
? "kbcx-grabbed border-indigo-500 dark:border-indigo-400"
: "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700"
}`}
>
<span className="flex items-center gap-1.5">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${PRIORITY_DOT[card.priority]}`}
aria-hidden="true"
/>
<span className="text-[10px] font-semibold tracking-wide text-slate-400 tabular-nums dark:text-slate-500">
{card.ref}
</span>
<span className="sr-only">{PRIORITY_LABEL[card.priority]}.</span>
<span
className={`ml-auto grid h-4 w-4 place-items-center rounded-full text-[8px] font-bold text-white ${owner.chip}`}
aria-hidden="true"
>
{owner.initials}
</span>
<span className="sr-only">Assigned to {owner.name}.</span>
</span>
<span
className={`mt-1 block font-medium leading-snug text-slate-800 dark:text-slate-100 ${
density === "cozy" ? "text-xs" : "line-clamp-2 text-[11px]"
}`}
>
{card.title}
</span>
{density === "cozy" ? (
<span className="mt-2 flex items-center gap-1.5">
<span className="rounded border border-slate-200 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:text-slate-400">
{card.tag}
</span>
<span
className={`inline-flex items-center gap-1 text-[10px] tabular-nums ${
complete
? "text-emerald-600 dark:text-emerald-400"
: "text-slate-500 dark:text-slate-400"
}`}
>
<svg
viewBox="0 0 16 16"
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 8.5 6 11.5 13 4.5" />
</svg>
{card.done}/{card.total}
</span>
<span className="sr-only">
{card.done} of {card.total} subtasks done.
</span>
</span>
) : null}
</button>
<span className="pointer-events-none absolute right-1.5 top-1.5 flex gap-0.5 rounded bg-white pl-2 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-slate-900">
{(["left", "right"] as const).map((dir) => {
const laneIdx = LANES.findIndex((l) => l.id === lane.id);
const nextLane = LANES[laneIdx + (dir === "left" ? -1 : 1)];
if (!nextLane) return null;
return (
<button
key={dir}
type="button"
tabIndex={-1}
onClick={() => shift(id, dir)}
aria-label={`Move ${card.ref} to ${nextLane.name}`}
className="grid h-4 w-4 place-items-center rounded border border-slate-200 bg-white text-slate-500 transition-colors hover:border-indigo-400 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-indigo-400 dark:hover:text-indigo-400"
>
<svg
viewBox="0 0 16 16"
className="h-2.5 w-2.5"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={dir === "left" ? "M10 3 5 8l5 5" : "M6 3l5 5-5 5"} />
</svg>
</button>
);
})}
</span>
</motion.li>
);
})}
{ids.length === 0 ? (
<li className="grid h-14 place-items-center rounded-lg border border-dashed border-slate-200 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-600">
Nothing here
</li>
) : null}
</ul>
</div>
);
})}
</div>
<footer className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4 dark:border-slate-800">
<p id="kbcx-help" className="text-[11px] text-slate-500 dark:text-slate-500">
Arrows move focus. Enter picks a card up, arrows then move it, Enter drops it, Escape
cancels.
</p>
<p className="text-[11px] tabular-nums text-slate-500 dark:text-slate-500">
{shippedCount} of {totalVisible} shipped
{filter === "all" ? "" : ` · ${PERSON_BY_ID[filter].name}`}
</p>
</footer>
<p aria-live="polite" className="sr-only">
{message}
</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 Sprint
Originalsprint board with progress

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.

