Kanban Board
Original · freekanban board with 3 columns and cards
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-board.json"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type ColumnId = "backlog" | "progress" | "shipped";
type Priority = "high" | "medium" | "low";
type Tag = "Checkout" | "Payments" | "Tech debt" | "Growth" | "Performance";
type Board = Record<ColumnId, string[]>;
type Dir = "up" | "down" | "left" | "right";
type Person = { initials: string; name: string; ring: string };
type Card = {
id: string;
title: string;
tag: Tag;
priority: Priority;
owner: Person;
done: number;
total: number;
comments: number;
};
type DragState = {
id: string;
from: ColumnId;
x: number;
y: number;
offX: number;
offY: number;
w: number;
};
type DropState = { col: ColumnId; index: number; y: number };
const ORDER: ColumnId[] = ["backlog", "progress", "shipped"];
const PEOPLE: Record<string, Person> = {
ar: { initials: "AR", name: "Amara Reyes", ring: "bg-indigo-500" },
km: { initials: "KM", name: "Kofi Mensah", ring: "bg-emerald-500" },
ts: { initials: "TS", name: "Tomas Sandberg", ring: "bg-amber-500" },
pn: { initials: "PN", name: "Priya Nair", ring: "bg-rose-500" },
};
const CARDS: Record<string, Card> = {
"c-guest": {
id: "c-guest",
title: "Guest checkout without forcing account creation",
tag: "Checkout",
priority: "high",
owner: PEOPLE.ar,
done: 2,
total: 5,
comments: 4,
},
"c-autocomplete": {
id: "c-autocomplete",
title: "Retire the legacy address autocomplete widget",
tag: "Tech debt",
priority: "low",
owner: PEOPLE.km,
done: 0,
total: 3,
comments: 1,
},
"c-applepay": {
id: "c-applepay",
title: "Apple Pay button on product detail pages",
tag: "Payments",
priority: "medium",
owner: PEOPLE.ts,
done: 1,
total: 4,
comments: 6,
},
"c-webhooks": {
id: "c-webhooks",
title: "Audit the 12 Stripe webhooks nothing listens to",
tag: "Tech debt",
priority: "low",
owner: PEOPLE.pn,
done: 0,
total: 2,
comments: 0,
},
"c-split": {
id: "c-split",
title: "Split the payment step into shipping + billing",
tag: "Checkout",
priority: "high",
owner: PEOPLE.pn,
done: 3,
total: 6,
comments: 9,
},
"c-vat": {
id: "c-vat",
title: "Fix VAT rounding on EU invoices over €1,000",
tag: "Payments",
priority: "high",
owner: PEOPLE.km,
done: 4,
total: 5,
comments: 12,
},
"c-reorder": {
id: "c-reorder",
title: "One-tap reorder for returning buyers",
tag: "Growth",
priority: "medium",
owner: PEOPLE.ts,
done: 2,
total: 4,
comments: 3,
},
"c-inline": {
id: "c-inline",
title: "Inline card validation with real-time field errors",
tag: "Checkout",
priority: "medium",
owner: PEOPLE.ar,
done: 5,
total: 5,
comments: 8,
},
"c-bundle": {
id: "c-bundle",
title: "Cut the checkout bundle from 412 KB to 189 KB",
tag: "Performance",
priority: "high",
owner: PEOPLE.pn,
done: 6,
total: 6,
comments: 15,
},
};
const INITIAL_BOARD: Board = {
backlog: ["c-guest", "c-applepay", "c-autocomplete", "c-webhooks"],
progress: ["c-vat", "c-split", "c-reorder"],
shipped: ["c-bundle", "c-inline"],
};
const COLUMN_META: Record<
ColumnId,
{ name: string; hint: string; limit: number | null; dot: string; pill: string }
> = {
backlog: {
name: "Backlog",
hint: "Groomed, estimated, ready to pull",
limit: null,
dot: "bg-slate-400 dark:bg-zinc-500",
pill: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300",
},
progress: {
name: "In progress",
hint: "WIP limit 3 — finish before you start",
limit: 3,
dot: "bg-indigo-500",
pill:
"bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
},
shipped: {
name: "Shipped",
hint: "Behind a flag or fully rolled out",
limit: null,
dot: "bg-emerald-500",
pill:
"bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
},
};
const TAG_STYLE: Record<Tag, string> = {
Checkout:
"bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
Payments:
"bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
"Tech debt":
"bg-slate-100 text-slate-600 ring-slate-200 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-zinc-700",
Growth:
"bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
Performance:
"bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
};
const PRIORITY_STYLE: Record<Priority, { label: string; cls: string }> = {
high: {
label: "High",
cls: "bg-rose-500/90 text-white dark:bg-rose-500/80",
},
medium: {
label: "Med",
cls: "bg-amber-400/90 text-amber-950 dark:bg-amber-400/80",
},
low: {
label: "Low",
cls: "bg-slate-300 text-slate-700 dark:bg-zinc-700 dark:text-zinc-200",
},
};
function columnOf(board: Board, id: string): ColumnId | null {
for (const col of ORDER) {
if (board[col].includes(id)) return col;
}
return null;
}
function moveCard(
board: Board,
id: string,
dir: Dir,
): { board: Board; col: ColumnId; index: number } | null {
const from = columnOf(board, id);
if (!from) return null;
const fromIdx = board[from].indexOf(id);
if (dir === "up" || dir === "down") {
const next = dir === "up" ? fromIdx - 1 : fromIdx + 1;
if (next < 0 || next >= board[from].length) return null;
const arr = board[from].slice();
arr.splice(fromIdx, 1);
arr.splice(next, 0, id);
return { board: { ...board, [from]: arr }, col: from, index: next };
}
const ci = ORDER.indexOf(from);
const ti = dir === "left" ? ci - 1 : ci + 1;
if (ti < 0 || ti >= ORDER.length) return null;
const to = ORDER[ti];
const src = board[from].slice();
src.splice(fromIdx, 1);
const tgt = board[to].slice();
const insert = Math.min(fromIdx, tgt.length);
tgt.splice(insert, 0, id);
return { board: { ...board, [from]: src, [to]: tgt }, col: to, index: insert };
}
function placeCard(
board: Board,
id: string,
to: ColumnId,
rawIndex: number,
): { board: Board; col: ColumnId; index: number } {
const from = columnOf(board, id);
if (!from) return { board, col: to, index: rawIndex };
const removeIdx = board[from].indexOf(id);
if (from === to) {
const arr = board[from].slice();
arr.splice(removeIdx, 1);
const insert = rawIndex > removeIdx ? rawIndex - 1 : rawIndex;
arr.splice(insert, 0, id);
return { board: { ...board, [from]: arr }, col: to, index: insert };
}
const src = board[from].slice();
src.splice(removeIdx, 1);
const tgt = board[to].slice();
const insert = Math.min(rawIndex, tgt.length);
tgt.splice(insert, 0, id);
return {
board: { ...board, [from]: src, [to]: tgt },
col: to,
index: insert,
};
}
function CardFace({ card }: { card: Card }) {
const pct = card.total === 0 ? 0 : Math.round((card.done / card.total) * 100);
const prio = PRIORITY_STYLE[card.priority];
return (
<div className="py-3.5 pl-5 pr-3.5">
<div className="flex items-start justify-between gap-2">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1 ring-inset ${TAG_STYLE[card.tag]}`}
>
{card.tag}
</span>
<span
className={`inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${prio.cls}`}
>
{prio.label}
</span>
</div>
<p className="mt-2.5 text-sm font-semibold leading-snug text-slate-800 dark:text-zinc-100">
{card.title}
</p>
<div className="mt-3">
<div className="flex items-center justify-between text-[11px] font-medium tabular-nums text-slate-400 dark:text-zinc-500">
<span>
{card.done}/{card.total} subtasks
</span>
<span>{pct}%</span>
</div>
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-zinc-800">
<div
className={`h-full rounded-full ${
pct === 100 ? "bg-emerald-500" : "bg-indigo-500 dark:bg-indigo-400"
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`grid h-6 w-6 place-items-center rounded-full text-[10px] font-bold text-white ${card.owner.ring}`}
>
{card.owner.initials}
</span>
<span className="text-[11px] font-medium text-slate-500 dark:text-zinc-400">
{card.owner.name}
</span>
</span>
<span className="flex items-center gap-1 text-[11px] font-medium tabular-nums text-slate-400 dark:text-zinc-500">
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.9 9.9 0 0 1-4-.9L3 21l1.9-4.6A8.4 8.4 0 1 1 21 11.5z" />
</svg>
{card.comments}
</span>
</div>
</div>
);
}
export default function KanbanBoard() {
const reduce = useReducedMotion();
const [board, setBoard] = useState<Board>(INITIAL_BOARD);
const [drag, setDrag] = useState<DragState | null>(null);
const [drop, setDrop] = useState<DropState | null>(null);
const [lifted, setLifted] = useState<string | null>(null);
const [message, setMessage] = useState("");
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const colRefs = useRef<Map<ColumnId, HTMLDivElement>>(new Map());
const listRefs = useRef<Map<ColumnId, HTMLDivElement>>(new Map());
const pendingRef = useRef<{
id: string;
col: ColumnId;
startX: number;
startY: number;
offX: number;
offY: number;
w: number;
pointerId: number;
} | null>(null);
const snapshotRef = useRef<Board | null>(null);
const totals = useMemo(
() => ORDER.reduce((n, c) => n + board[c].length, 0),
[board],
);
const announcePlace = useCallback(
(id: string, col: ColumnId, index: number, verb: string) => {
setMessage(
`${CARDS[id].title} ${verb} ${COLUMN_META[col].name}, position ${index + 1}.`,
);
},
[],
);
const computeDrop = useCallback(
(clientX: number, clientY: number): DropState | null => {
for (const col of ORDER) {
const colEl = colRefs.current.get(col);
const listEl = listRefs.current.get(col);
if (!colEl || !listEl) continue;
const cr = colEl.getBoundingClientRect();
if (clientX < cr.left || clientX > cr.right) continue;
const ids = board[col];
const rects = ids.map((id) =>
cardRefs.current.get(id)?.getBoundingClientRect(),
);
let index = ids.length;
for (let i = 0; i < ids.length; i++) {
const r = rects[i];
if (!r) continue;
if (clientY < r.top + r.height / 2) {
index = i;
break;
}
}
const listRect = listEl.getBoundingClientRect();
let y: number;
if (ids.length === 0) {
y = 10;
} else if (index === 0) {
const first = rects[0];
y = first ? first.top - listRect.top - 5 : 10;
} else {
const prev = rects[index - 1];
y = prev ? prev.bottom - listRect.top + 5 : listRect.height - 10;
}
return { col, index, y };
}
return null;
},
[board],
);
const startPending = useCallback(
(e: ReactPointerEvent, id: string, col: ColumnId) => {
const el = cardRefs.current.get(id);
if (!el) return;
const r = el.getBoundingClientRect();
pendingRef.current = {
id,
col,
startX: e.clientX,
startY: e.clientY,
offX: e.clientX - r.left,
offY: e.clientY - r.top,
w: r.width,
pointerId: e.pointerId,
};
el.setPointerCapture(e.pointerId);
},
[],
);
const onCardPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>, id: string, col: ColumnId) => {
if (e.pointerType === "touch") return;
if (e.button !== 0) return;
startPending(e, id, col);
},
[startPending],
);
const onHandlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLSpanElement>, id: string, col: ColumnId) => {
e.stopPropagation();
startPending(e, id, col);
},
[startPending],
);
const onCardPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const p = pendingRef.current;
if (!p || p.pointerId !== e.pointerId) return;
if (!drag) {
const dist = Math.hypot(e.clientX - p.startX, e.clientY - p.startY);
if (dist < 5) return;
setLifted(null);
snapshotRef.current = null;
setDrag({
id: p.id,
from: p.col,
x: e.clientX,
y: e.clientY,
offX: p.offX,
offY: p.offY,
w: p.w,
});
} else {
setDrag({ ...drag, x: e.clientX, y: e.clientY });
}
setDrop(computeDrop(e.clientX, e.clientY));
},
[drag, computeDrop],
);
const onCardPointerUp = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const p = pendingRef.current;
if (p && p.pointerId === e.pointerId) {
const el = cardRefs.current.get(p.id);
if (el && el.hasPointerCapture(e.pointerId)) {
el.releasePointerCapture(e.pointerId);
}
}
if (drag) {
if (drop) {
const next = placeCard(board, drag.id, drop.col, drop.index);
setBoard(next.board);
announcePlace(drag.id, next.col, next.index, "moved to");
} else {
setMessage(`${CARDS[drag.id].title} stayed where it was.`);
}
setDrag(null);
setDrop(null);
}
pendingRef.current = null;
},
[drag, drop, board, announcePlace],
);
const onCardPointerCancel = useCallback(() => {
pendingRef.current = null;
setDrag(null);
setDrop(null);
}, []);
const focusNeighbour = useCallback(
(id: string, dir: Dir) => {
const from = columnOf(board, id);
if (!from) return;
const idx = board[from].indexOf(id);
let targetId: string | undefined;
if (dir === "up" || dir === "down") {
targetId = board[from][dir === "up" ? idx - 1 : idx + 1];
} else {
const ti = ORDER.indexOf(from) + (dir === "left" ? -1 : 1);
if (ti < 0 || ti >= ORDER.length) return;
const col = board[ORDER[ti]];
targetId = col[Math.min(idx, col.length - 1)];
}
if (targetId) cardRefs.current.get(targetId)?.focus();
},
[board],
);
const onCardKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>, id: string) => {
const isLifted = lifted === id;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isLifted) {
const col = columnOf(board, id);
const index = col ? board[col].indexOf(id) : 0;
setLifted(null);
snapshotRef.current = null;
if (col) announcePlace(id, col, index, "dropped in");
} else {
const col = columnOf(board, id);
const index = col ? board[col].indexOf(id) : 0;
snapshotRef.current = board;
setLifted(id);
setMessage(
`${CARDS[id].title} lifted from ${col ? COLUMN_META[col].name : ""}, position ${index + 1}. Use the arrow keys to move it, Enter to drop, Escape to cancel.`,
);
}
return;
}
if (e.key === "Escape" && isLifted) {
e.preventDefault();
if (snapshotRef.current) setBoard(snapshotRef.current);
snapshotRef.current = null;
setLifted(null);
setMessage(`Move cancelled. ${CARDS[id].title} is back where it was.`);
return;
}
const dirs: Record<string, Dir> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
const dir = dirs[e.key];
if (!dir) return;
e.preventDefault();
if (!isLifted) {
focusNeighbour(id, dir);
return;
}
const next = moveCard(board, id, dir);
if (!next) {
setMessage("Cannot move any further in that direction.");
return;
}
setBoard(next.board);
announcePlace(id, next.col, next.index, "moved to");
},
[board, lifted, announcePlace, focusNeighbour],
);
useEffect(() => {
if (lifted) cardRefs.current.get(lifted)?.focus();
}, [board, lifted]);
const resetBoard = useCallback(() => {
setBoard(INITIAL_BOARD);
setLifted(null);
snapshotRef.current = null;
setMessage("Board reset to the start of the sprint.");
}, []);
const dragCard = drag ? CARDS[drag.id] : null;
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:py-24 dark:bg-zinc-950">
<style>{`
@keyframes knb-lift-ring {
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
50% { box-shadow: 0 0 0 7px rgba(99, 102, 241, 0); }
}
@keyframes knb-line-glow {
0%, 100% { opacity: 0.65; }
50% { opacity: 1; }
}
@keyframes knb-sheen {
0% { transform: translateX(-130%); }
100% { transform: translateX(330%); }
}
.knb-lifted { animation: knb-lift-ring 1.7s ease-out infinite; }
.knb-line { animation: knb-line-glow 1.1s ease-in-out infinite; }
.knb-sheen { animation: knb-sheen 2.6s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.knb-lifted, .knb-line, .knb-sheen { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-6xl">
{/* Board header */}
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-zinc-500">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inset-0 rounded-full bg-emerald-500" />
</span>
Sprint 24 · closes Friday
</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-zinc-50">
Checkout revamp
</h2>
<p className="mt-1.5 max-w-md text-sm text-slate-500 dark:text-zinc-400">
{totals} cards across three lanes. Drag with a mouse, or focus a
card and press Enter to move it with the arrow keys.
</p>
</div>
<div className="flex items-center gap-2">
<span className="hidden items-center -space-x-2 sm:flex">
{Object.values(PEOPLE).map((p) => (
<span
key={p.initials}
title={p.name}
className={`grid h-8 w-8 place-items-center rounded-full text-[11px] font-bold text-white ring-2 ring-slate-50 dark:ring-zinc-950 ${p.ring}`}
>
{p.initials}
</span>
))}
</span>
<button
type="button"
onClick={resetBoard}
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-2 text-sm font-semibold text-slate-600 ring-1 ring-inset ring-slate-200 transition-colors hover:bg-slate-100 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-slate-50 dark:bg-zinc-900 dark:text-zinc-300 dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-offset-zinc-950"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
</svg>
Reset
</button>
</div>
</div>
<p
id="knb-instructions"
className="mt-6 rounded-lg border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-xs text-slate-500 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-400"
>
<span className="font-semibold text-slate-700 dark:text-zinc-200">
Keyboard:
</span>{" "}
Tab to a card, arrows to browse, Enter to pick it up, arrows to move it
between lanes, Enter to drop, Escape to cancel.
</p>
{/* Columns */}
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{ORDER.map((col) => {
const meta = COLUMN_META[col];
const ids = board[col];
const over = meta.limit !== null && ids.length > meta.limit;
const isTarget = drop?.col === col && drag !== null;
return (
<div
key={col}
ref={(el) => {
if (el) colRefs.current.set(col, el);
else colRefs.current.delete(col);
}}
className={`flex flex-col rounded-2xl border bg-white/70 transition-colors duration-150 dark:bg-zinc-900/50 ${
isTarget
? "border-indigo-400 bg-indigo-50/60 dark:border-indigo-500/60 dark:bg-indigo-500/5"
: "border-slate-200 dark:border-zinc-800"
} ${col === "shipped" ? "sm:col-span-2 lg:col-span-1" : ""}`}
>
<div className="flex items-start justify-between gap-2 px-3.5 pb-2 pt-3.5">
<div className="min-w-0">
<h3 className="flex items-center gap-2 text-sm font-bold text-slate-800 dark:text-zinc-100">
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${meta.dot}`}
/>
{meta.name}
<span
className={`rounded-full px-1.5 py-0.5 text-[11px] font-bold tabular-nums ${meta.pill}`}
>
{ids.length}
</span>
</h3>
<p className="mt-1 truncate text-[11px] text-slate-400 dark:text-zinc-500">
{meta.hint}
</p>
</div>
{over && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-md bg-amber-50 px-1.5 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-700 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30">
<svg
viewBox="0 0 24 24"
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 8v5M12 16.5v.01" />
<path d="M10.3 3.9 2.4 17.4A1.9 1.9 0 0 0 4 20.3h16a1.9 1.9 0 0 0 1.6-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0z" />
</svg>
Over WIP
</span>
)}
</div>
<div
ref={(el) => {
if (el) listRefs.current.set(col, el);
else listRefs.current.delete(col);
}}
role="list"
aria-label={`${meta.name}, ${ids.length} cards`}
className="relative min-h-[7rem] flex-1 space-y-2.5 px-2.5 pb-3"
>
{isTarget && drop && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2.5 z-10 h-0.5 rounded-full bg-indigo-500 dark:bg-indigo-400 ${
reduce ? "" : "knb-line"
}`}
style={{ top: drop.y }}
/>
)}
{ids.length === 0 && (
<p
role="listitem"
className="grid h-24 place-items-center rounded-xl border border-dashed border-slate-300 text-xs font-medium text-slate-400 dark:border-zinc-700 dark:text-zinc-500"
>
Nothing here yet
</p>
)}
{ids.map((id) => {
const card = CARDS[id];
const isDragging = drag?.id === id;
const isLifted = lifted === id;
const index = ids.indexOf(id);
return (
<motion.div
key={id}
layout={!reduce}
transition={{
type: "spring",
stiffness: 520,
damping: 42,
}}
ref={(el: HTMLDivElement | null) => {
if (el) cardRefs.current.set(id, el);
else cardRefs.current.delete(id);
}}
role="listitem"
tabIndex={0}
aria-roledescription="Draggable card"
aria-describedby="knb-instructions"
aria-label={`${card.title}. ${card.tag}, ${PRIORITY_STYLE[card.priority].label} priority, owned by ${card.owner.name}. ${COLUMN_META[col].name}, position ${index + 1} of ${ids.length}.`}
onPointerDown={(e) => onCardPointerDown(e, id, col)}
onPointerMove={onCardPointerMove}
onPointerUp={onCardPointerUp}
onPointerCancel={onCardPointerCancel}
onKeyDown={(e) => onCardKeyDown(e, id)}
className={`group relative select-none rounded-xl border bg-white text-left shadow-sm outline-none transition-shadow dark:bg-zinc-900 ${
isDragging
? "border-slate-200 opacity-40 dark:border-zinc-800"
: "border-slate-200 hover:shadow-md dark:border-zinc-800"
} ${
isLifted
? "border-indigo-400 dark:border-indigo-500 " +
(reduce ? "ring-2 ring-indigo-500" : "knb-lifted")
: ""
} focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-zinc-950 cursor-grab active:cursor-grabbing`}
>
{isLifted && (
<span className="absolute -top-2 left-3 z-10 rounded bg-indigo-600 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
Held
</span>
)}
<span
onPointerDown={(e) => onHandlePointerDown(e, id, col)}
aria-hidden="true"
className="absolute inset-y-0 left-0 grid w-4 touch-none place-items-center rounded-l-xl text-slate-300 transition-colors hover:bg-slate-50 hover:text-slate-500 dark:text-zinc-700 dark:hover:bg-zinc-800/60 dark:hover:text-zinc-400"
>
<svg
viewBox="0 0 6 16"
className="h-4 w-1.5"
fill="currentColor"
>
<circle cx="1.5" cy="4" r="1" />
<circle cx="4.5" cy="4" r="1" />
<circle cx="1.5" cy="8" r="1" />
<circle cx="4.5" cy="8" r="1" />
<circle cx="1.5" cy="12" r="1" />
<circle cx="4.5" cy="12" r="1" />
</svg>
</span>
<CardFace card={card} />
</motion.div>
);
})}
</div>
</div>
);
})}
</div>
<span className="sr-only" role="status" aria-live="polite">
{message}
</span>
</div>
{/* Floating drag preview */}
{drag && dragCard && (
<div
aria-hidden="true"
className="pointer-events-none fixed z-50 overflow-hidden rounded-xl border border-indigo-300 bg-white shadow-2xl shadow-slate-900/20 dark:border-indigo-500/50 dark:bg-zinc-900 dark:shadow-black/60"
style={{
left: drag.x - drag.offX,
top: drag.y - drag.offY,
width: drag.w,
transform: reduce ? undefined : "rotate(2.5deg) scale(1.03)",
}}
>
<span className="pointer-events-none absolute inset-0 overflow-hidden">
<span
className={`absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-indigo-400/15 to-transparent ${
reduce ? "" : "knb-sheen"
}`}
/>
</span>
<CardFace card={dragCard} />
</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 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

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.

