"use client";
import * as React from "react";
import { animate, motion, useMotionValue } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface KanbanCard {
/** Stable, unique id across the whole board. */
id: string;
/** Visible title; also used in screen reader announcements. */
title: string;
description?: string;
/** Small label shown by the default card renderer. */
tag?: string;
}
export interface KanbanColumn<C extends KanbanCard = KanbanCard> {
id: string;
title: string;
cards: C[];
/** Work-in-progress limit. The header shows count / limit and flags the column when it is exceeded. */
wipLimit?: number;
}
export interface KanbanCardState {
/** Rendered inside the lifted drag ghost. */
dragging: boolean;
/** Picked up with the keyboard. */
grabbed: boolean;
columnId: string;
index: number;
}
export interface KanbanBoardProps<C extends KanbanCard = KanbanCard>
extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"children" | "onChange" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Columns and their cards (controlled). */
columns: KanbanColumn<C>[];
/** Called with the new columns after every move, add or remove. */
onChange: (columns: KanbanColumn<C>[]) => void;
/** Renders a card's body. The draggable shell is provided. */
renderCard?: (card: C, state: KanbanCardState) => React.ReactNode;
/** Shows an "Add card" button per column. Return a card to append it (or update `columns` yourself). */
onAddCard?: (columnId: string) => C | void;
/** Shows a remove button on each card. */
removable?: boolean;
/** Called when a card is removed (after onChange). */
onRemoveCard?: (card: C, columnId: string) => void;
/** Show the WIP count / limit indicator in column headers. */
showWip?: boolean;
/** Column width in px. */
columnWidth?: number;
/** Maximum height of a column's card list before it scrolls, in px. */
maxColumnHeight?: number;
/** Scroll columns and the board while dragging near their edges. */
autoScroll?: boolean;
/** Tilt of the lifted ghost, in degrees. */
tilt?: number;
/** Spring for cards reflowing around the placeholder. */
spring?: Spring;
/** Accent for the lifted card, placeholder and focus rings. */
accent?: string;
/** Accessible name of the board. */
label?: string;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const DRAG_THRESHOLD = 4; // px before a press becomes a drag
const EDGE = 48; // px from a scroll edge where auto-scroll starts
const MAX_SPEED = 14; // px per frame
const GAP = 8; // px between cards (matches gap-2)
function locate<C extends KanbanCard>(columns: KanbanColumn<C>[], cardId: string) {
for (let c = 0; c < columns.length; c++) {
const index = columns[c].cards.findIndex((card) => card.id === cardId);
if (index >= 0) return { columnIndex: c, index, card: columns[c].cards[index] };
}
return null;
}
/** Returns new columns with the card moved to `toColumn` at `toIndex` (clamped). */
function moveCard<C extends KanbanCard>(columns: KanbanColumn<C>[], cardId: string, toColumn: string, toIndex: number) {
const from = locate(columns, cardId);
if (!from) return columns;
const without = columns.map((col, c) => (c === from.columnIndex ? { ...col, cards: col.cards.filter((card) => card.id !== cardId) } : col));
return without.map((col) => {
if (col.id !== toColumn) return col;
const cards = col.cards.slice();
cards.splice(Math.max(0, Math.min(cards.length, toIndex)), 0, from.card);
return { ...col, cards };
});
}
function scrollSpeed(pos: number, start: number, end: number) {
if (pos < start + EDGE) return -MAX_SPEED * Math.min(1, (start + EDGE - pos) / EDGE);
if (pos > end - EDGE) return MAX_SPEED * Math.min(1, (pos - (end - EDGE)) / EDGE);
return 0;
}
interface DragState {
cardId: string;
fromColumn: string;
fromIndex: number;
toColumn: string;
toIndex: number;
width: number;
height: number;
dropping: boolean;
}
/**
* A Kanban board with pointer and keyboard drag and drop. Pointer drags lift a tilted ghost into the top
* layer while an animated placeholder shows where the card will land; columns auto-scroll near their edges.
* From the keyboard, Space picks a card up, arrows move it within and between columns, Space drops and
* Escape cancels, with every step announced in a polite live region.
*/
export function KanbanBoard<C extends KanbanCard = KanbanCard>({
columns,
onChange,
renderCard,
onAddCard,
removable = false,
onRemoveCard,
showWip = true,
columnWidth = 272,
maxColumnHeight = 420,
autoScroll = true,
tilt = 2,
spring = { stiffness: 520, damping: 42, mass: 1 },
accent = "#FF7448",
label = "Board",
className = "",
style,
...props
}: KanbanBoardProps<C>) {
const uid = React.useId();
const instructionsId = `${uid}-instructions`;
const reduceMotion = usePrefersReducedMotion();
const [drag, setDrag] = React.useState<DragState | null>(null);
const [grabbed, setGrabbed] = React.useState<{ cardId: string; snapshot: KanbanColumn<C>[] } | null>(null);
const [announcement, setAnnouncement] = React.useState("");
const boardRef = React.useRef<HTMLDivElement>(null);
const columnRefs = React.useRef(new Map<string, HTMLElement>());
const listRefs = React.useRef(new Map<string, HTMLUListElement>());
const cardRefs = React.useRef(new Map<string, HTMLLIElement>());
const handleRefs = React.useRef(new Map<string, HTMLDivElement>());
const refocusRef = React.useRef<string | null>(null);
const liveRef = React.useRef<{ drag: DragState | null; x: number; y: number; frame: number; cleanup: (() => void) | null }>({
drag: null,
x: 0,
y: 0,
frame: 0,
cleanup: null,
});
const ghostX = useMotionValue(0);
const ghostY = useMotionValue(0);
// While dragging, cards render from the previewed order so the placeholder sits where the card will land.
const shown = drag ? moveCard(columns, drag.cardId, drag.toColumn, drag.toIndex) : columns;
const titleOf = (id: string) => locate(columns, id)?.card.title ?? "card";
const columnTitle = (id: string) => columns.find((col) => col.id === id)?.title ?? id;
const positionText = (cols: KanbanColumn<C>[], cardId: string) => {
const at = locate(cols, cardId);
if (!at) return "";
const col = cols[at.columnIndex];
return `${col.title}, position ${at.index + 1} of ${col.cards.length}`;
};
/* ------------------------------ focus helpers ------------------------------ */
/** Scrolls the card's column list and the board (never the page) so the card is visible. */
const revealCard = React.useCallback((cardId: string) => {
const card = cardRefs.current.get(cardId);
const list = card?.parentElement;
if (!card || !list) return;
const top = card.offsetTop;
const bottom = top + card.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top - GAP;
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight + GAP;
const board = boardRef.current;
const column = card.closest<HTMLElement>("[data-kanban-column]");
if (board && column) {
const b = board.getBoundingClientRect();
const r = column.getBoundingClientRect();
if (r.left < b.left) board.scrollLeft -= b.left - r.left + 12;
else if (r.right > b.right) board.scrollLeft += r.right - b.right + 12;
}
}, []);
// A moved card can re-mount in another column; put focus back on it after each change.
React.useEffect(() => {
const id = refocusRef.current;
if (!id) return;
refocusRef.current = null;
const handle = handleRefs.current.get(id);
if (handle && document.activeElement !== handle) handle.focus({ preventScroll: true });
revealCard(id);
}, [columns, revealCard]);
// Stop listeners and loops if the board unmounts mid-drag.
React.useEffect(() => {
const live = liveRef.current;
return () => {
cancelAnimationFrame(live.frame);
live.cleanup?.();
};
}, []);
/* -------------------------------- pointer -------------------------------- */
const updateDrag = (next: DragState | null) => {
liveRef.current.drag = next;
setDrag(next);
};
/** Works out which column and slot the pointer is over, ignoring the placeholder's own height. */
const computeTarget = () => {
const live = liveRef.current;
const d = live.drag;
if (!d || d.dropping) return;
let toColumn = d.toColumn;
let best = Infinity;
columnRefs.current.forEach((el, id) => {
const r = el.getBoundingClientRect();
const dist = live.x < r.left ? r.left - live.x : live.x > r.right ? live.x - r.right : 0;
if (dist < best) {
best = dist;
toColumn = id;
}
});
const list = listRefs.current.get(toColumn);
if (!list) return;
const y = live.y - list.getBoundingClientRect().top + list.scrollTop;
let toIndex = 0;
let shift = 0;
for (const el of Array.from(list.querySelectorAll<HTMLElement>(":scope > [data-card-id]"))) {
if (el.dataset.cardId === d.cardId) {
shift = el.offsetHeight + GAP;
continue;
}
if (el.offsetTop - shift + el.offsetHeight / 2 < y) toIndex++;
}
if (toColumn !== d.toColumn || toIndex !== d.toIndex) updateDrag({ ...d, toColumn, toIndex });
};
const autoScrollTick = () => {
const live = liveRef.current;
const d = live.drag;
if (!d || d.dropping) return;
let scrolled = false;
const list = listRefs.current.get(d.toColumn);
if (list && list.scrollHeight > list.clientHeight) {
const r = list.getBoundingClientRect();
const v = live.x >= r.left && live.x <= r.right ? scrollSpeed(live.y, r.top, r.bottom) : 0;
if (v) {
list.scrollTop += v;
scrolled = true;
}
}
const board = boardRef.current;
if (board && board.scrollWidth > board.clientWidth) {
const r = board.getBoundingClientRect();
const v = scrollSpeed(live.x, r.left, r.right);
if (v) {
board.scrollLeft += v;
scrolled = true;
}
}
if (scrolled) computeTarget();
live.frame = requestAnimationFrame(autoScrollTick);
};
const finishDrop = () => {
liveRef.current.drag = null;
setDrag(null);
};
/** Flies the ghost onto the card's resting place, then removes it. */
const settleGhost = (cardId: string) => {
requestAnimationFrame(() => {
const target = cardRefs.current.get(cardId)?.getBoundingClientRect();
if (!target || reduceMotion) {
finishDrop();
return;
}
const settle = { type: "spring" as const, stiffness: 600, damping: 45 };
animate(ghostX, target.left, settle);
animate(ghostY, target.top, settle).then(finishDrop);
});
};
const endPointerDrag = (cancelled: boolean) => {
const live = liveRef.current;
cancelAnimationFrame(live.frame);
live.cleanup?.();
live.cleanup = null;
const d = live.drag;
if (!d) return;
const name = titleOf(d.cardId);
if (cancelled) {
updateDrag({ ...d, toColumn: d.fromColumn, toIndex: d.fromIndex, dropping: true });
setAnnouncement(`Move cancelled. ${name} returned to ${positionText(columns, d.cardId)}.`);
} else {
const next = moveCard(columns, d.cardId, d.toColumn, d.toIndex);
updateDrag({ ...d, dropping: true });
if (d.toColumn !== d.fromColumn || d.toIndex !== d.fromIndex) {
onChange(next);
setAnnouncement(`Moved ${name} to ${positionText(next, d.cardId)}.`);
}
}
settleGhost(d.cardId);
};
const onCardPointerDown = (event: React.PointerEvent<HTMLDivElement>, card: C, columnId: string, index: number) => {
if (event.button !== 0 || grabbed || liveRef.current.drag) return;
// On touch, only the grip starts a drag so the column can still be scrolled with a finger.
if (event.pointerType === "touch" && !(event.target as Element).closest("[data-kanban-grip]")) return;
const el = cardRefs.current.get(card.id);
if (!el) return;
const startX = event.clientX;
const startY = event.clientY;
const pointerId = event.pointerId;
let started = false;
let offsetX = 0;
let offsetY = 0;
const onMove = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
const live = liveRef.current;
live.x = e.clientX;
live.y = e.clientY;
if (!started) {
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD) return;
started = true;
const rect = el.getBoundingClientRect();
offsetX = startX - rect.left;
offsetY = startY - rect.top;
ghostX.set(rect.left);
ghostY.set(rect.top);
window.getSelection()?.removeAllRanges();
updateDrag({
cardId: card.id,
fromColumn: columnId,
fromIndex: index,
toColumn: columnId,
toIndex: index,
width: rect.width,
height: rect.height,
dropping: false,
});
if (autoScroll) live.frame = requestAnimationFrame(autoScrollTick);
}
e.preventDefault();
ghostX.set(e.clientX - offsetX);
ghostY.set(e.clientY - offsetY);
computeTarget();
};
const onUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
if (started) endPointerDrag(false);
else cleanup();
};
const onCancel = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
if (started) endPointerDrag(true);
else cleanup();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && started) {
e.preventDefault();
e.stopPropagation();
endPointerDrag(true);
}
};
// Stop the browser from scrolling or selecting text once a touch drag has begun on the grip.
const onTouchMove = (e: TouchEvent) => {
if (started) e.preventDefault();
};
const cleanup = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("touchmove", onTouchMove);
if (liveRef.current.cleanup === cleanup) liveRef.current.cleanup = null;
};
// Window listeners: the card may re-mount in another column mid-drag, which would drop pointer capture.
window.addEventListener("pointermove", onMove, { passive: false });
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("keydown", onKey, true);
window.addEventListener("touchmove", onTouchMove, { passive: false });
liveRef.current.cleanup = cleanup;
};
/* -------------------------------- keyboard -------------------------------- */
const commitKeyboardMove = (card: C, toColumnIndex: number, toIndex: number) => {
const target = columns[toColumnIndex];
const from = locate(columns, card.id);
if (!target || !from) return;
const max = target.cards.length - (from.columnIndex === toColumnIndex ? 1 : 0);
const index = Math.max(0, Math.min(max, toIndex));
if (from.columnIndex === toColumnIndex && index === from.index) return;
const next = moveCard(columns, card.id, target.id, index);
refocusRef.current = card.id;
onChange(next);
setAnnouncement(`Moved ${card.title} to ${positionText(next, card.id)}.`);
};
const onCardKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, card: C) => {
if (drag) return;
const at = locate(columns, card.id);
if (!at) return;
const isGrabbed = grabbed?.cardId === card.id;
if (!isGrabbed) {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
setGrabbed({ cardId: card.id, snapshot: columns });
setAnnouncement(
`Picked up ${card.title}, ${positionText(columns, card.id)}. Use the arrow keys to move it, Space to drop, Escape to cancel.`,
);
}
return;
}
switch (event.key) {
case "ArrowUp":
event.preventDefault();
commitKeyboardMove(card, at.columnIndex, at.index - 1);
break;
case "ArrowDown":
event.preventDefault();
commitKeyboardMove(card, at.columnIndex, at.index + 1);
break;
case "ArrowLeft":
event.preventDefault();
if (at.columnIndex > 0) commitKeyboardMove(card, at.columnIndex - 1, at.index);
break;
case "ArrowRight":
event.preventDefault();
if (at.columnIndex < columns.length - 1) commitKeyboardMove(card, at.columnIndex + 1, at.index);
break;
case "Home":
event.preventDefault();
commitKeyboardMove(card, at.columnIndex, 0);
break;
case "End":
event.preventDefault();
commitKeyboardMove(card, at.columnIndex, Number.MAX_SAFE_INTEGER);
break;
case " ":
case "Enter":
event.preventDefault();
setGrabbed(null);
setAnnouncement(`Dropped ${card.title} in ${positionText(columns, card.id)}.`);
break;
case "Escape": {
event.preventDefault();
// Keep Escape from also closing a surrounding dialog.
event.stopPropagation();
const snapshot = grabbed?.snapshot;
setGrabbed(null);
if (snapshot) {
refocusRef.current = card.id;
onChange(snapshot);
setAnnouncement(`Move cancelled. ${card.title} returned to ${positionText(snapshot, card.id)}.`);
}
break;
}
case "Tab":
setGrabbed(null);
setAnnouncement(`Dropped ${card.title} in ${positionText(columns, card.id)}.`);
break;
}
};
/* ------------------------------ add / remove ------------------------------ */
const addCard = (columnId: string) => {
const created = onAddCard?.(columnId);
if (!created) return;
const next = columns.map((col) => (col.id === columnId ? { ...col, cards: [...col.cards, created] } : col));
refocusRef.current = created.id;
onChange(next);
setAnnouncement(`Added ${created.title} to ${columnTitle(columnId)}.`);
};
const removeCard = (card: C, columnId: string) => {
const at = locate(columns, card.id);
if (!at) return;
const cards = columns[at.columnIndex].cards;
const neighbour = cards[at.index + 1] ?? cards[at.index - 1];
const next = columns.map((col) => (col.id === columnId ? { ...col, cards: col.cards.filter((c) => c.id !== card.id) } : col));
if (neighbour) refocusRef.current = neighbour.id;
onChange(next);
onRemoveCard?.(card, columnId);
setAnnouncement(`Removed ${card.title} from ${columnTitle(columnId)}.`);
};
/* --------------------------------- render --------------------------------- */
const layoutTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const draggedCard = drag ? locate(columns, drag.cardId)?.card : undefined;
const cardBody = (card: C, state: KanbanCardState) =>
renderCard ? (
renderCard(card, state)
) : (
<>
{card.tag && (
<span className="mb-1.5 inline-block rounded-[5px] border border-zinc-900/10 px-1.5 py-px font-mono text-[9px] uppercase tracking-[0.12em] text-zinc-500 dark:border-white/10 dark:text-zinc-400">
{card.tag}
</span>
)}
<span className="block text-[13px] font-medium leading-snug tracking-tight">{card.title}</span>
{card.description && <span className="mt-1 block text-[12px] leading-snug text-zinc-500 dark:text-zinc-400">{card.description}</span>}
</>
);
return (
<div
role="region"
aria-label={label}
className={`relative w-full min-w-0 text-zinc-900 dark:text-zinc-100 ${className}`}
style={{ ["--kb-accent" as string]: accent, ...style }}
{...props}
>
<p id={instructionsId} className="sr-only">
Press Space or Enter to pick up a card. While it is picked up, use the up and down arrows to move it within a column,
left and right arrows to move it to another column, Space or Enter to drop it, and Escape to cancel.
</p>
<motion.div
ref={boardRef}
layoutScroll
className={`flex items-start gap-3 overflow-x-auto overscroll-x-contain pb-2 ${drag ? "cursor-grabbing select-none" : ""}`}
>
{shown.map((column) => {
const count = column.cards.length;
const limit = column.wipLimit;
const over = limit !== undefined && count > limit;
const headingId = `${uid}-col-${column.id}`;
return (
<section
key={column.id}
ref={(node) => {
if (node) columnRefs.current.set(column.id, node);
else columnRefs.current.delete(column.id);
}}
data-kanban-column={column.id}
aria-labelledby={headingId}
className={`flex shrink-0 flex-col rounded-2xl border bg-zinc-50 transition-colors dark:bg-zinc-900/70 ${
drag && drag.toColumn === column.id && !drag.dropping
? "border-[color-mix(in_oklab,var(--kb-accent)_45%,transparent)]"
: "border-zinc-900/[0.08] dark:border-white/[0.08]"
}`}
style={{ width: columnWidth }}
>
<header className="px-3 pb-2 pt-3">
<div className="flex items-center justify-between gap-2">
<h3 id={headingId} className="truncate text-[13px] font-semibold tracking-tight">
{column.title}
</h3>
{showWip && (
<span
className={`shrink-0 rounded-md px-1.5 py-0.5 font-mono text-[10px] tabular-nums tracking-[0.06em] ${
over
? "bg-red-500/10 text-red-600 dark:text-red-400"
: "bg-zinc-900/[0.05] text-zinc-500 dark:bg-white/[0.06] dark:text-zinc-400"
}`}
>
{count}
{limit !== undefined && ` / ${limit}`}
<span className="sr-only">
{" "}
{count === 1 ? "card" : "cards"}
{limit !== undefined && (over ? ", over the work-in-progress limit" : ", work-in-progress limit")}
</span>
</span>
)}
</div>
{showWip && limit !== undefined && (
<div aria-hidden="true" className="mt-2 h-[3px] overflow-hidden rounded-full bg-zinc-900/[0.06] dark:bg-white/[0.06]">
<div
className={`h-full origin-left rounded-full transition-[scale,background-color] duration-300 ${over ? "bg-red-500" : "bg-[var(--kb-accent)]"}`}
style={{ scale: `${Math.min(1, count / Math.max(1, limit))} 1` }}
/>
</div>
)}
</header>
<motion.ul
ref={(node: HTMLUListElement | null) => {
if (node) listRefs.current.set(column.id, node);
else listRefs.current.delete(column.id);
}}
layoutScroll
aria-labelledby={headingId}
className="relative flex min-h-16 flex-col gap-2 overflow-y-auto overscroll-y-contain px-2 pb-2"
style={{ maxHeight: maxColumnHeight }}
>
{column.cards.map((card, index) => {
const isPlaceholder = drag?.cardId === card.id;
const isGrabbed = grabbed?.cardId === card.id;
return (
<motion.li
key={card.id}
ref={(node: HTMLLIElement | null) => {
if (node) cardRefs.current.set(card.id, node);
else cardRefs.current.delete(card.id);
}}
data-card-id={card.id}
// layoutId lets a card glide between columns; useId keeps it unique per board.
layoutId={`${uid}-${card.id}`}
layout={!reduceMotion}
transition={layoutTransition}
className="group/card relative shrink-0"
>
{isPlaceholder ? (
<div
aria-hidden="true"
className="rounded-xl border border-dashed border-[color-mix(in_oklab,var(--kb-accent)_60%,transparent)] bg-[color-mix(in_oklab,var(--kb-accent)_8%,transparent)]"
style={{ height: drag?.height }}
/>
) : (
<>
<div
ref={(node) => {
if (node) handleRefs.current.set(card.id, node);
else handleRefs.current.delete(card.id);
}}
role="button"
tabIndex={0}
aria-roledescription="draggable card"
aria-describedby={instructionsId}
aria-pressed={isGrabbed}
onPointerDown={(event) => onCardPointerDown(event, card, column.id, index)}
onKeyDown={(event) => onCardKeyDown(event, card)}
onBlur={() => {
// Focus leaving a lifted card (other than our own refocus after a move) drops it in place.
if (isGrabbed && refocusRef.current !== card.id) {
setGrabbed(null);
setAnnouncement(`Dropped ${card.title} in ${positionText(columns, card.id)}.`);
}
}}
className={`relative block cursor-grab select-none rounded-xl border bg-white px-3 py-2.5 text-left outline-hidden transition-[border-color,box-shadow,scale] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--kb-accent)] dark:bg-zinc-950 ${
removable ? "pr-9" : ""
} ${
isGrabbed
? "scale-[1.02] border-[var(--kb-accent)] shadow-[0_14px_30px_-14px_rgba(0,0,0,0.35)] dark:shadow-[0_14px_30px_-10px_rgba(0,0,0,0.9)]"
: "border-zinc-900/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] hover:border-zinc-900/20 dark:border-white/[0.08] dark:hover:border-white/20"
}`}
>
{cardBody(card, { dragging: false, grabbed: isGrabbed, columnId: column.id, index })}
<span
data-kanban-grip=""
aria-hidden="true"
className="absolute bottom-1.5 right-1.5 flex h-6 w-5 touch-none items-center justify-center rounded-md text-zinc-300 dark:text-zinc-600"
>
<svg viewBox="0 0 10 16" className="h-3 w-2" fill="currentColor">
{[3, 8, 13].map((y) => (
<React.Fragment key={y}>
<circle cx="2.5" cy={y} r="1.25" />
<circle cx="7.5" cy={y} r="1.25" />
</React.Fragment>
))}
</svg>
</span>
</div>
{removable && (
<button
type="button"
aria-label={`Remove ${card.title}`}
onClick={() => removeCard(card, column.id)}
className="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-md text-zinc-400 opacity-0 outline-hidden transition-[opacity,background-color,color] hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--kb-accent)] group-hover/card:opacity-100 group-focus-within/card:opacity-100 dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100 [@media(hover:none)]:opacity-100"
>
<svg viewBox="0 0 12 12" className="size-2.5" fill="none" aria-hidden="true">
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
</svg>
</button>
)}
</>
)}
</motion.li>
);
})}
{column.cards.length === 0 && (
<li
aria-hidden="true"
className="flex h-16 items-center justify-center rounded-xl border border-dashed border-zinc-900/10 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/10 dark:text-zinc-500"
>
Drop here
</li>
)}
</motion.ul>
{onAddCard && (
<div className="px-2 pb-2">
<button
type="button"
onClick={() => addCard(column.id)}
className="flex h-8 w-full items-center gap-1.5 rounded-lg px-2 text-[12px] font-medium text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--kb-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-zinc-100"
>
<svg viewBox="0 0 12 12" className="size-3" fill="none" aria-hidden="true">
<path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
</svg>
Add card<span className="sr-only"> to {column.title}</span>
</button>
</div>
)}
</section>
);
})}
</motion.div>
{drag && draggedCard && (
<div
ref={(node) => {
// Top layer: never clipped by the board and immune to transformed ancestors.
if (node && typeof node.showPopover === "function" && !node.matches(":popover-open")) {
try {
node.showPopover();
} catch {
// Unsupported: position: fixed still applies.
}
}
}}
popover="manual"
aria-hidden="true"
className="pointer-events-none fixed inset-0 m-0 h-full max-h-none w-full max-w-none overflow-visible border-0 bg-transparent p-0"
>
<motion.div
className="absolute left-0 top-0"
style={{ x: ghostX, y: ghostY, width: drag.width }}
>
<motion.div
initial={reduceMotion ? false : { rotate: 0, scale: 1 }}
animate={drag.dropping || reduceMotion ? { rotate: 0, scale: 1 } : { rotate: tilt, scale: 1.03 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="cursor-grabbing rounded-xl border border-[var(--kb-accent)] bg-white px-3 py-2.5 text-zinc-900 shadow-[0_24px_48px_-18px_rgba(0,0,0,0.45),0_4px_10px_rgba(0,0,0,0.08)] dark:bg-zinc-950 dark:text-zinc-100 dark:shadow-[0_24px_48px_-12px_rgba(0,0,0,0.9)]"
>
{cardBody(draggedCard, { dragging: true, grabbed: false, columnId: drag.toColumn, index: drag.toIndex })}
</motion.div>
</motion.div>
</div>
)}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
</div>
);
}