Kanban Swimlane
Original · freeswimlane grouped 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-swimlane.json"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ColumnId = "triage" | "building" | "review" | "shipped";
type LaneId = "acquisition" | "platform" | "design";
type TagId = "SEO" | "Copy" | "Growth" | "Infra" | "Perf" | "A11y" | "Docs";
type Column = {
id: ColumnId;
label: string;
wip: number | null;
};
type Lane = {
id: LaneId;
name: string;
meta: string;
dot: string;
rail: string;
chip: string;
};
type Card = {
id: string;
lane: LaneId;
col: ColumnId;
title: string;
tag: TagId;
owner: string;
initials: string;
points: number;
blocked: boolean;
};
const COLUMNS: Column[] = [
{ id: "triage", label: "Triage", wip: null },
{ id: "building", label: "Building", wip: 3 },
{ id: "review", label: "In review", wip: null },
{ id: "shipped", label: "Shipped", wip: null },
];
const LANES: Lane[] = [
{
id: "acquisition",
name: "Acquisition squad",
meta: "Priya, Dana, Salman",
dot: "bg-sky-500",
rail: "bg-sky-500/70",
chip: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
},
{
id: "platform",
name: "Platform & infra",
meta: "Marco, Ivo",
dot: "bg-violet-500",
rail: "bg-violet-500/70",
chip: "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
},
{
id: "design",
name: "Design systems",
meta: "Lena, Noor",
dot: "bg-emerald-500",
rail: "bg-emerald-500/70",
chip: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
},
];
const TAG_STYLES: Record<TagId, string> = {
SEO: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
Copy: "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
Growth: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30",
Infra: "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
Perf: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
A11y: "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
Docs: "bg-zinc-100 text-zinc-700 ring-zinc-200 dark:bg-zinc-500/10 dark:text-zinc-300 dark:ring-zinc-500/30",
};
const INITIAL_CARDS: Card[] = [
{
id: "acq-1",
lane: "acquisition",
col: "triage",
title: "Rewrite the pricing hero for the self-serve tier",
tag: "Copy",
owner: "Priya Nair",
initials: "PN",
points: 3,
blocked: false,
},
{
id: "acq-2",
lane: "acquisition",
col: "triage",
title: "Merge the duplicate /features and /product pages",
tag: "SEO",
owner: "Salman Riaz",
initials: "SR",
points: 5,
blocked: true,
},
{
id: "acq-3",
lane: "acquisition",
col: "building",
title: "Ship the exit-intent offer to 10% of traffic",
tag: "Growth",
owner: "Dana Kovac",
initials: "DK",
points: 2,
blocked: false,
},
{
id: "acq-4",
lane: "acquisition",
col: "review",
title: "Service schema markup on 12 landing pages",
tag: "SEO",
owner: "Priya Nair",
initials: "PN",
points: 3,
blocked: false,
},
{
id: "acq-5",
lane: "acquisition",
col: "shipped",
title: "Cut blog index LCP from 4.1s to 1.8s",
tag: "Perf",
owner: "Dana Kovac",
initials: "DK",
points: 2,
blocked: false,
},
{
id: "plt-1",
lane: "platform",
col: "triage",
title: "Move the image pipeline off the legacy CDN",
tag: "Infra",
owner: "Marco Vidal",
initials: "MV",
points: 8,
blocked: false,
},
{
id: "plt-2",
lane: "platform",
col: "building",
title: "Rate-limit the public /api/lead endpoint",
tag: "Infra",
owner: "Ivo Tanev",
initials: "IT",
points: 3,
blocked: true,
},
{
id: "plt-3",
lane: "platform",
col: "building",
title: "Connection pooling for preview databases",
tag: "Infra",
owner: "Marco Vidal",
initials: "MV",
points: 5,
blocked: false,
},
{
id: "plt-4",
lane: "platform",
col: "review",
title: "Nightly backup restore drill",
tag: "Infra",
owner: "Ivo Tanev",
initials: "IT",
points: 2,
blocked: false,
},
{
id: "plt-5",
lane: "platform",
col: "shipped",
title: "Structured logs on every edge function",
tag: "Infra",
owner: "Marco Vidal",
initials: "MV",
points: 3,
blocked: false,
},
{
id: "dsn-1",
lane: "design",
col: "triage",
title: "Fix contrast on the amber alert tokens",
tag: "A11y",
owner: "Lena Fischer",
initials: "LF",
points: 2,
blocked: false,
},
{
id: "dsn-2",
lane: "design",
col: "triage",
title: "Write the swimlane board spec for the docs site",
tag: "Docs",
owner: "Noor Abadi",
initials: "NA",
points: 3,
blocked: false,
},
{
id: "dsn-3",
lane: "design",
col: "building",
title: "Focus-visible ring pass across 40 components",
tag: "A11y",
owner: "Lena Fischer",
initials: "LF",
points: 5,
blocked: false,
},
{
id: "dsn-4",
lane: "design",
col: "shipped",
title: "Dark-mode tokens for dense data tables",
tag: "Docs",
owner: "Noor Abadi",
initials: "NA",
points: 3,
blocked: false,
},
];
const HINT_ID = "kbsw-hint";
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ArrowIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function BlockedIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.6" />
<path d="M4.2 4.2l7.6 7.6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function ResetIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="none" className={className}>
<path
d="M13 8a5 5 0 11-1.6-3.7M13 2v3h-3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function colIndex(id: ColumnId): number {
return COLUMNS.findIndex((c) => c.id === id);
}
function laneName(id: LaneId): string {
const lane = LANES.find((l) => l.id === id);
return lane ? lane.name : id;
}
function columnLabel(id: ColumnId): string {
const col = COLUMNS.find((c) => c.id === id);
return col ? col.label : id;
}
function reorderWithin(list: Card[], id: string, delta: number): Card[] {
const card = list.find((c) => c.id === id);
if (!card) return list;
const positions: number[] = [];
list.forEach((c, i) => {
if (c.lane === card.lane && c.col === card.col) positions.push(i);
});
const at = positions.indexOf(list.indexOf(card));
const to = at + delta;
if (at < 0 || to < 0 || to >= positions.length) return list;
const next = list.slice();
const a = positions[at];
const b = positions[to];
const tmp = next[a];
next[a] = next[b];
next[b] = tmp;
return next;
}
export default function KanbanSwimlane() {
const reduce = useReducedMotion();
const [cards, setCards] = useState<Card[]>(INITIAL_CARDS);
const [openLanes, setOpenLanes] = useState<Record<LaneId, boolean>>({
acquisition: true,
platform: true,
design: true,
});
const [blockedOnly, setBlockedOnly] = useState(false);
const [grabbedId, setGrabbedId] = useState<string | null>(null);
const [message, setMessage] = useState("");
const snapshot = useRef<Card[]>(INITIAL_CARDS);
const cardRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
const pendingFocus = useRef<string | null>(null);
useEffect(() => {
const id = pendingFocus.current;
if (!id) return;
pendingFocus.current = null;
cardRefs.current.get(id)?.focus();
});
const visible = useMemo(
() => (blockedOnly ? cards.filter((c) => c.blocked) : cards),
[cards, blockedOnly],
);
const groupOf = (lane: LaneId, col: ColumnId): Card[] =>
visible.filter((c) => c.lane === lane && c.col === col);
const columnTotals = useMemo(() => {
const totals: Record<ColumnId, number> = { triage: 0, building: 0, review: 0, shipped: 0 };
for (const c of visible) totals[c.col] += 1;
return totals;
}, [visible]);
const grabbed = grabbedId ? cards.find((c) => c.id === grabbedId) ?? null : null;
function pickUp(card: Card) {
snapshot.current = cards;
setGrabbedId(card.id);
setMessage(`Picked up ${card.title}. Use arrow keys to move it, Enter to drop, Escape to cancel.`);
}
function drop(card: Card) {
setGrabbedId(null);
setMessage(`Dropped ${card.title} in ${columnLabel(card.col)}, ${laneName(card.lane)}.`);
}
function cancel(card: Card) {
setCards(snapshot.current);
setGrabbedId(null);
pendingFocus.current = card.id;
setMessage(`Move cancelled. ${card.title} is back in its original place.`);
}
function moveToColumn(card: Card, col: ColumnId) {
if (col === card.col) return;
setCards((prev) => prev.map((c) => (c.id === card.id ? { ...c, col } : c)));
pendingFocus.current = card.id;
setMessage(`${card.title} moved to ${columnLabel(col)} in ${laneName(card.lane)}.`);
}
function shiftColumn(card: Card, dir: -1 | 1) {
const next = COLUMNS[colIndex(card.col) + dir];
if (!next) {
setMessage(`${card.title} is already in ${columnLabel(card.col)}.`);
return;
}
moveToColumn(card, next.id);
}
function shiftRow(card: Card, delta: -1 | 1) {
setCards((prev) => reorderWithin(prev, card.id, delta));
pendingFocus.current = card.id;
setMessage(`${card.title} moved ${delta < 0 ? "up" : "down"} in ${columnLabel(card.col)}.`);
}
function focusNeighbour(card: Card, dx: number, dy: number) {
if (dy !== 0) {
const column = groupOf(card.lane, card.col);
const at = column.findIndex((c) => c.id === card.id);
const target = column[at + dy];
if (target) cardRefs.current.get(target.id)?.focus();
return;
}
const group = groupOf(card.lane, card.col);
const at = Math.max(0, group.findIndex((c) => c.id === card.id));
let i = colIndex(card.col) + dx;
while (i >= 0 && i < COLUMNS.length) {
const candidates = groupOf(card.lane, COLUMNS[i].id);
if (candidates.length > 0) {
const target = candidates[Math.min(at, candidates.length - 1)];
cardRefs.current.get(target.id)?.focus();
return;
}
i += dx;
}
}
function onCardKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>, card: Card) {
const held = grabbedId === card.id;
switch (event.key) {
case "ArrowLeft":
event.preventDefault();
if (held) shiftColumn(card, -1);
else focusNeighbour(card, -1, 0);
break;
case "ArrowRight":
event.preventDefault();
if (held) shiftColumn(card, 1);
else focusNeighbour(card, 1, 0);
break;
case "ArrowUp":
event.preventDefault();
if (held) shiftRow(card, -1);
else focusNeighbour(card, 0, -1);
break;
case "ArrowDown":
event.preventDefault();
if (held) shiftRow(card, 1);
else focusNeighbour(card, 0, 1);
break;
case "Escape":
if (held) {
event.preventDefault();
cancel(card);
}
break;
default:
break;
}
}
function toggleLane(id: LaneId) {
setOpenLanes((prev) => ({ ...prev, [id]: !prev[id] }));
setGrabbedId(null);
}
function resetBoard() {
setCards(INITIAL_CARDS);
snapshot.current = INITIAL_CARDS;
setGrabbedId(null);
setBlockedOnly(false);
setMessage("Board reset to the start of the sprint.");
}
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes kbsw-hold {
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 kbsw-slot-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.kbsw-hold { animation: kbsw-hold 1.9s ease-out infinite; }
.kbsw-slot { animation: kbsw-slot-in 220ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
.kbsw-hold, .kbsw-slot { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-6xl">
<header className="mb-8 flex flex-col gap-6 sm:mb-10 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Sprint 24 · day 6 of 10
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Delivery board
</h2>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Three squads, one board. Each swimlane keeps its own row so a stuck ticket in Platform never
hides behind Acquisition’s backlog.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
role="switch"
aria-checked={blockedOnly}
onClick={() => {
setBlockedOnly((v) => !v);
setGrabbedId(null);
setMessage(blockedOnly ? "Showing all cards." : "Showing blocked cards only.");
}}
className="inline-flex items-center gap-2.5 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm 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-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition-colors ${
blockedOnly ? "bg-rose-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className="absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all duration-200"
style={{ left: blockedOnly ? "1.125rem" : "0.125rem" }}
/>
</span>
Blocked only
</button>
<button
type="button"
onClick={resetBoard}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm 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-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
>
<ResetIcon className="h-4 w-4" />
Reset board
</button>
</div>
</header>
<p
id={HINT_ID}
className="mb-4 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs leading-relaxed text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400"
>
{grabbed ? (
<span className="font-medium text-indigo-700 dark:text-indigo-300">
Moving “{grabbed.title}” — ← → changes column, ↑ ↓ reorders,
Enter drops, Esc cancels.
</span>
) : (
<>
Focus a card and press <kbd className="rounded border border-slate-300 px-1 font-sans dark:border-slate-700">Enter</kbd>{" "}
to pick it up, then use the arrow keys. Arrow keys alone move focus between cards.
</>
)}
</p>
<div className="overflow-x-auto pb-2">
<div className="min-w-[56rem]">
<div className="grid grid-cols-4 gap-3">
{COLUMNS.map((col) => {
const count = columnTotals[col.id];
const over = col.wip !== null && count > col.wip;
return (
<div
key={col.id}
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 dark:border-slate-800 dark:bg-slate-900"
>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{col.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ring-1 ring-inset ${
over
? "bg-amber-50 text-amber-700 ring-amber-300 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/40"
: "bg-slate-100 text-slate-600 ring-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:ring-slate-700"
}`}
title={col.wip !== null ? `WIP limit ${col.wip}` : undefined}
>
{col.wip !== null ? `${count}/${col.wip}` : count}
</span>
</div>
);
})}
</div>
<div className="mt-3 space-y-3">
{LANES.map((lane) => {
const open = openLanes[lane.id];
const laneCards = visible.filter((c) => c.lane === lane.id);
const points = laneCards.reduce((sum, c) => sum + c.points, 0);
const done = laneCards.filter((c) => c.col === "shipped").length;
return (
<section
key={lane.id}
aria-labelledby={`kbsw-lane-${lane.id}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900/60"
>
<h3 id={`kbsw-lane-${lane.id}`} className="m-0">
<button
type="button"
aria-expanded={open}
aria-controls={`kbsw-lanebody-${lane.id}`}
onClick={() => toggleLane(lane.id)}
className="flex w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:hover:bg-slate-800/60"
>
<ChevronIcon
className={`h-4 w-4 shrink-0 text-slate-500 transition-transform duration-200 dark:text-slate-400 ${
open ? "" : "-rotate-90"
}`}
/>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${lane.dot}`} aria-hidden="true" />
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{lane.name}</span>
<span className="hidden text-xs text-slate-500 sm:inline dark:text-slate-500">{lane.meta}</span>
<span className="ml-auto flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${lane.chip}`}>
{points} pts
</span>
<span className="text-xs tabular-nums text-slate-500 dark:text-slate-500">
{done}/{laneCards.length} shipped
</span>
</span>
</button>
</h3>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="body"
id={`kbsw-lanebody-${lane.id}`}
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.28, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="relative grid grid-cols-4 gap-3 border-t border-slate-200 p-3 dark:border-slate-800">
<span
aria-hidden="true"
className={`absolute left-0 top-0 h-full w-0.5 ${lane.rail}`}
/>
{COLUMNS.map((col) => {
const group = groupOf(lane.id, col.id);
const canDrop = grabbed !== null && grabbed.lane === lane.id && grabbed.col !== col.id;
return (
<div key={col.id} className="min-w-0">
<ul className="flex list-none flex-col gap-2 p-0">
{group.map((card) => {
const held = grabbedId === card.id;
const left = COLUMNS[colIndex(card.col) - 1];
const right = COLUMNS[colIndex(card.col) + 1];
return (
<motion.li
key={card.id}
layoutId={reduce ? undefined : `kbsw-card-${card.id}`}
transition={{ duration: reduce ? 0 : 0.26, ease: "easeOut" }}
className={`relative rounded-lg border bg-white transition-colors dark:bg-slate-900 ${
held
? "kbsw-hold border-indigo-400 dark:border-indigo-500"
: "border-slate-200 dark:border-slate-800"
}`}
>
<button
type="button"
ref={(el) => {
cardRefs.current.set(card.id, el);
}}
aria-pressed={held}
aria-describedby={HINT_ID}
onClick={() => (held ? drop(card) : pickUp(card))}
onKeyDown={(event) => onCardKeyDown(event, card)}
className="block w-full rounded-lg p-2.5 pr-14 text-left 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"
>
<span className="block text-[13px] font-medium leading-snug text-slate-900 dark:text-slate-100">
{card.title}
</span>
<span className="mt-2 flex flex-wrap items-center gap-1.5">
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset ${TAG_STYLES[card.tag]}`}
>
{card.tag}
</span>
{card.blocked && (
<span className="inline-flex items-center gap-1 rounded bg-rose-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-700 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30">
<BlockedIcon className="h-2.5 w-2.5" />
Blocked
</span>
)}
</span>
<span className="mt-2.5 flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-200 text-[9px] font-bold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{card.initials}
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{card.owner}
</span>
<span className="ml-auto text-[11px] font-semibold tabular-nums text-slate-400 dark:text-slate-500">
{card.points}
</span>
</span>
</button>
<div className="absolute right-1.5 top-1.5 z-10 flex gap-0.5">
<button
type="button"
disabled={!left}
onClick={() => shiftColumn(card, -1)}
aria-label={
left
? `Move ${card.title} to ${left.label}`
: `${card.title} is in the first column`
}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-30 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<ArrowIcon className="h-3.5 w-3.5 rotate-180" />
</button>
<button
type="button"
disabled={!right}
onClick={() => shiftColumn(card, 1)}
aria-label={
right
? `Move ${card.title} to ${right.label}`
: `${card.title} is in the last column`
}
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-30 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<ArrowIcon className="h-3.5 w-3.5" />
</button>
</div>
</motion.li>
);
})}
{canDrop && grabbed && (
<li className="kbsw-slot list-none">
<button
type="button"
onClick={() => {
moveToColumn(grabbed, col.id);
setGrabbedId(null);
}}
className="w-full rounded-lg border border-dashed border-indigo-400 bg-indigo-50/60 px-2 py-3 text-[11px] font-medium text-indigo-700 transition-colors hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-indigo-500/60 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
>
Drop in {col.label}
</button>
</li>
)}
{group.length === 0 && !canDrop && (
<li className="rounded-lg border border-dashed border-slate-200 px-2 py-4 text-center text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-600">
Empty
</li>
)}
</ul>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</section>
);
})}
</div>
</div>
</div>
<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 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.

