Kanban board
Columns of cards a reader moves by pointer or by keyboard, with WIP limits and a live region that says every move.
The rows stay yours. The board renders each card where its own column says and reports every move through onMove(cardId, column, index) — it never reorders anything itself, so the order lives in one reducer of your own rather than in two places that can disagree. index is the position in the destination column after the card has been taken out of wherever it was, so splicing it in at that index is the whole move. The pointer half is window-level: the press is captured on the card, the move and the release are read off window, so a drag that leaves the board — or leaves the window — still ends, and where it lands is measured rather than guessed (the column under the pointer, then the slot among the cards whose midpoints it has passed). A press that never travels five pixels is a click, not a drag, and a control inside a card — a link, a button, anything marked data-no-drag — keeps its own press, which is how a card can be both the drag surface and hold an "open" button. The keyboard half needs no pointer at all: left and right move a focused card between columns, up and down reorder it inside one, space picks it up and puts it down, escape puts it back where it started; focus follows the card to wherever it lands. Both halves announce through one polite role="status" region, which names the card by the text you rendered in it unless cardLabel says otherwise. A column over its limit renders data-over-limit, wears a warning count, and the move that put it there says so out loud. Columns scroll sideways inside the board, so a five-column board never makes the page scroll.
Install
npx shadcn@latest add @vibra/kanban-boardNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { KanbanBoard, type KanbanColumnSpec } from "@/components/ui/kanban-board"
import { PriorityBadge, type Priority } from "@/components/ui/priority-badge"
type Ticket = { id: string; column: string; title: string; priority: Priority; owner: string }
const COLUMNS: KanbanColumnSpec[] = [
{ id: "todo", title: "To do" },
{ id: "doing", title: "In progress", limit: 3 },
{ id: "review", title: "In review", limit: 2 },
{ id: "done", title: "Done" },
]
const TICKETS: Ticket[] = [
{ id: "t1", column: "todo", title: "Rate-limit the webhook retries", priority: "high", owner: "Mira Vance" },
{ id: "t2", column: "todo", title: "Document the export queue", priority: "low", owner: "Owen Bell" },
{ id: "t3", column: "todo", title: "Split the settings API", priority: "medium", owner: "Priya Raman" },
{ id: "t4", column: "doing", title: "Backfill the usage rollup", priority: "urgent", owner: "Jonas Hart" },
{ id: "t5", column: "doing", title: "Harden the session store", priority: "medium", owner: "Mira Vance" },
{ id: "t6", column: "review", title: "Version the price ladder", priority: "high", owner: "Elena Ruiz" },
{ id: "t7", column: "done", title: "Retire the legacy mailer", priority: "low", owner: "Owen Bell" },
]
const initials = (name: string) =>
name
.split(" ")
.map((part) => part[0])
.join("")
export default function KanbanBoardDemo() {
const [cards, setCards] = React.useState(TICKETS)
return (
<KanbanBoard<Ticket>
aria-label="Sprint 41"
className="w-full"
columns={COLUMNS}
cards={cards}
cardLabel={(card) => card.title}
onMove={(id, column, index) =>
setCards((rows) => {
const moving = rows.find((row) => row.id === id)!
const rest = rows.filter((row) => row.id !== id)
const landing = rest.filter((row) => row.column === column)
landing.splice(index, 0, { ...moving, column })
return COLUMNS.flatMap((spec) =>
spec.id === column ? landing : rest.filter((row) => row.column === spec.id)
)
})
}
renderCard={(card) => (
<div className="flex flex-col gap-2">
<p className="text-sm leading-snug">{card.title}</p>
<div className="flex items-center justify-between gap-2">
<PriorityBadge priority={card.priority} size="sm" />
<Avatar className="size-5">
<AvatarFallback className="text-avatar">{initials(card.owner)}</AvatarFallback>
</Avatar>
</div>
</div>
)}
/>
)
}Without a pointer
Every move on the board made from the keyboard alone.
"use client"
import * as React from "react"
import { KanbanBoard, type KanbanColumnSpec } from "@/components/ui/kanban-board"
import { Kbd } from "@/components/ui/kbd"
type Note = { id: string; column: string; title: string }
const COLUMNS: KanbanColumnSpec[] = [
{ id: "inbox", title: "Inbox" },
{ id: "week", title: "This week", limit: 2 },
{ id: "done", title: "Done" },
]
const NOTES: Note[] = [
{ id: "n1", column: "inbox", title: "Read the incident write-up" },
{ id: "n2", column: "inbox", title: "Reply to the security questionnaire" },
{ id: "n3", column: "week", title: "Draft the pricing memo" },
{ id: "n4", column: "done", title: "Book the offsite room" },
]
/** Every move on this board can be made without a pointer. */
export default function KanbanBoardKeyboard() {
const [cards, setCards] = React.useState(NOTES)
return (
<div className="flex w-full flex-col gap-3">
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
Tab to a card, then <Kbd>←</Kbd> <Kbd>→</Kbd> to change column, <Kbd>↑</Kbd> <Kbd>↓</Kbd> to
reorder, <Kbd>Space</Kbd> to pick it up and drop it, <Kbd>Esc</Kbd> to put it back.
</p>
<KanbanBoard<Note>
aria-label="Notes"
size="sm"
columns={COLUMNS}
cards={cards}
cardLabel={(card) => card.title}
onMove={(id, column, index) =>
setCards((rows) => {
const moving = rows.find((row) => row.id === id)!
const rest = rows.filter((row) => row.id !== id)
const landing = rest.filter((row) => row.column === column)
landing.splice(index, 0, { ...moving, column })
return COLUMNS.flatMap((spec) =>
spec.id === column ? landing : rest.filter((row) => row.column === spec.id)
)
})
}
renderCard={(card) => <p className="text-xs leading-snug">{card.title}</p>}
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | { id: string; title: string; limit?: number; meta?: React.ReactNode }[] | — | The columns, in order. title is what the live region says; meta is a second line under it — a total, a share of the pipeline. |
| cards | (T & { id: string; column: string })[] | — | Your rows, each carrying the column it belongs to. Order within a column is the array's. |
| renderCard | (card: T & { id: string; column: string }) => React.ReactNode | — | What a card shows. The board draws the shell, the lift and the focus ring around it. |
| onMove | (cardId: string, column: string, index: number) => void | — | A card has been put somewhere. Splice it into that column at that index; a move that would leave it exactly where it is is never reported. |
| cardLabel | (card) => string | the card's own text | How the live region names a card, when the rendered text is longer than a sentence. |
| size | "sm" | "default" | "default" | Card padding and type scale. |
| emptyMessage | React.ReactNode | "Nothing here" | What an empty column says, so it reads as empty rather than as missing. |
| headingLevel | 2 | 3 | 4 | 3 | The level the column titles are headings at. A board under a card's h2 keeps the default; a page whose h1 sits straight over the board passes 2, or its outline skips a level. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { KanbanCardShell } from "./card"
import { KanbanColumnShell } from "./column"
import { DRAG_THRESHOLD, cardText, dropTargetAt, type DropTarget } from "./geometry"
export type KanbanColumnSpec = {
id: string
title: string
/** The WIP limit. A column holding more than this is marked and announced. */
limit?: number
/** A second line under the title — the sum of what is in the column, a share. */
meta?: React.ReactNode
}
/** A row of yours, plus the two fields the board needs to place it. */
export type KanbanCard<T> = T & { id: string; column: string }
export type KanbanBoardProps<T> = React.ComponentProps<"div"> & {
columns: KanbanColumnSpec[]
cards: KanbanCard<T>[]
renderCard: (card: KanbanCard<T>) => React.ReactNode
/**
* A card has been put somewhere. `index` is its position in the destination
* column *after* it has been taken out of wherever it was, so splicing it in
* at that index is the whole move.
*/
onMove: (cardId: string, column: string, index: number) => void
/** How the live region names a card; by default, the text you rendered in it. */
cardLabel?: (card: KanbanCard<T>) => string
size?: "sm" | "default"
/** What an empty column says. */
emptyMessage?: React.ReactNode
/** The level the column titles are headings at: 2 for a board straight under a page's h1. */
headingLevel?: 2 | 3 | 4
}
const ARROWS: Record<string, "up" | "down" | "left" | "right"> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
}
/**
* Columns of cards a reader can move by pointer or by keyboard.
*
* The rows are yours: the board renders `cards` where their `column` says, and
* reports every move through `onMove` — it never reorders anything itself, so
* one state update in your own reducer is the only place the order lives.
*
* The pointer half is window-level: the press is captured on the card, the
* move and the release are read off `window` — so a drag that leaves the board
* still ends — and where it lands is measured rather than guessed (see
* `geometry.ts`). The keyboard half needs no pointer at all: arrows move a
* focused card between columns and within one, Space picks it up and puts it
* down, Escape puts it back. Both halves say what happened in one live region.
*/
function KanbanBoard<T>({
className,
columns,
cards,
renderCard,
onMove,
cardLabel,
size = "default",
emptyMessage,
headingLevel,
"aria-label": ariaLabel = "Board",
...props
}: KanbanBoardProps<T>) {
const hintId = React.useId()
const boardNode = React.useRef<HTMLDivElement | null>(null)
const columnNodes = React.useRef(new Map<string, HTMLElement | null>())
const cardNodes = React.useRef(new Map<string, HTMLElement | null>())
const pointer = React.useRef<
{ id: string; pointerId: number; x: number; y: number; active: boolean } | null
>(null)
/** Which card to put focus back on once the move has re-rendered the board. */
const pendingFocus = React.useRef<string | null>(null)
const [dragging, setDragging] = React.useState<string | null>(null)
const [drop, setDrop] = React.useState<DropTarget | null>(null)
/** Where a keyboard-held card started, so Escape can put it back. */
const [held, setHeld] = React.useState<{ id: string; column: string; index: number } | null>(null)
const [announcement, setAnnouncement] = React.useState("")
// Everything the window listeners need, in a ref: they are registered once,
// and a stale `cards` array in a closure would drop a card in the wrong slot.
// Written in an effect rather than during render, and read only from an
// event handler — which always runs after the effect that filled it in.
const latest = React.useRef({ columns, cards, onMove, cardLabel })
React.useEffect(() => {
latest.current = { columns, cards, onMove, cardLabel }
})
React.useEffect(() => {
const id = pendingFocus.current
if (!id) return
pendingFocus.current = null
cardNodes.current.get(id)?.focus()
})
const labelOf = React.useCallback((id: string): string => {
const { cards: rows, cardLabel: naming } = latest.current
const card = rows.find((row) => row.id === id)
if (card && naming) return naming(card)
return cardText(cardNodes.current.get(id))
}, [])
/**
* Moves a card and says so. A move that would put the card back exactly
* where it already is is not a move, and neither reorders nor announces.
*/
const commit = React.useCallback(
(id: string, column: string, index: number) => {
const { columns: specs, cards: rows, onMove: move } = latest.current
const card = rows.find((row) => row.id === id)
const spec = specs.find((entry) => entry.id === column)
if (!card || !spec) return
const here = rows.filter((row) => row.column === card.column).findIndex((row) => row.id === id)
if (card.column === column && here === index) return
const label = labelOf(id)
const total = rows.filter((row) => row.column === column && row.id !== id).length + 1
const over = spec.limit !== undefined && total > spec.limit
pendingFocus.current = id
move(id, column, index)
setAnnouncement(
`${label} moved to ${spec.title}, position ${index + 1} of ${total}.` +
(over ? ` ${spec.title} is over its limit of ${spec.limit}.` : "")
)
},
[labelOf]
)
/** Which column and which slot the pointer is over, measured off the DOM. */
const targetAt = React.useCallback((x: number, y: number, dragId: string): DropTarget | null => {
const { columns: specs, cards: rows } = latest.current
return dropTargetAt({
columns: specs.map((spec) => spec.id),
cards: rows,
columnNodes: columnNodes.current,
cardNodes: cardNodes.current,
board: boardNode.current,
dragId,
x,
y,
})
}, [])
React.useEffect(() => {
function handleMove(event: PointerEvent) {
const drag = pointer.current
if (!drag || event.pointerId !== drag.pointerId) return
if (!drag.active) {
const travelled = Math.abs(event.clientX - drag.x) + Math.abs(event.clientY - drag.y)
if (travelled < DRAG_THRESHOLD) return
drag.active = true
setDragging(drag.id)
}
// Stops the page from selecting text under the card as it is dragged.
event.preventDefault()
setDrop(targetAt(event.clientX, event.clientY, drag.id))
}
function handleUp(event: PointerEvent) {
const drag = pointer.current
if (!drag || event.pointerId !== drag.pointerId) return
pointer.current = null
setDragging(null)
setDrop(null)
if (!drag.active) return
const target = targetAt(event.clientX, event.clientY, drag.id)
// Outside the board entirely (the header, a sidebar): the card stays
// exactly where it was, and the live region says the drop did not happen
// rather than leaving a reader who heard the pick-up wondering.
if (target) commit(drag.id, target.column, target.index)
else setAnnouncement("Move cancelled.")
}
window.addEventListener("pointermove", handleMove)
window.addEventListener("pointerup", handleUp)
window.addEventListener("pointercancel", handleUp)
return () => {
window.removeEventListener("pointermove", handleMove)
window.removeEventListener("pointerup", handleUp)
window.removeEventListener("pointercancel", handleUp)
}
}, [commit, targetAt])
function handlePointerDown(event: React.PointerEvent<HTMLLIElement>, id: string) {
if (event.button !== 0) return
// A control the card renders keeps its own press: a link, a menu button,
// a checkbox on the card is not a handle for the card.
if ((event.target as HTMLElement).closest("a,button,input,select,textarea,[data-no-drag]")) return
pointer.current = { id, pointerId: event.pointerId, x: event.clientX, y: event.clientY, active: false }
// The card keeps receiving the pointer even once it is dragged out from
// under it; the events still bubble to the window listeners above.
event.currentTarget.setPointerCapture?.(event.pointerId)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLLIElement>, card: KanbanCard<T>) {
if (event.key === " " || event.key === "Spacebar") {
event.preventDefault()
if (held?.id === card.id) {
const spec = columns.find((entry) => entry.id === card.column)
setHeld(null)
setAnnouncement(`${labelOf(card.id)} dropped in ${spec?.title ?? card.column}.`)
return
}
const index = cards.filter((row) => row.column === card.column).findIndex((row) => row.id === card.id)
setHeld({ id: card.id, column: card.column, index })
setAnnouncement(`${labelOf(card.id)} picked up. Use the arrow keys to move it, space to drop it.`)
return
}
if (event.key === "Escape") {
if (held?.id !== card.id) return
event.preventDefault()
commit(card.id, held.column, held.index)
setHeld(null)
setAnnouncement("Move cancelled.")
return
}
const direction = ARROWS[event.key]
if (!direction) return
const members = cards.filter((row) => row.column === card.column)
const index = members.findIndex((row) => row.id === card.id)
if (direction === "up" || direction === "down") {
const next = index + (direction === "down" ? 1 : -1)
if (next < 0 || next >= members.length) return
event.preventDefault()
commit(card.id, card.column, next)
return
}
const at = columns.findIndex((entry) => entry.id === card.column)
const next = at + (direction === "right" ? 1 : -1)
if (next < 0 || next >= columns.length) return
event.preventDefault()
const destination = columns[next]
const occupants = cards.filter((row) => row.column === destination.id).length
// The same row of the destination where that column has one, its end
// otherwise — a card never lands in a gap the column does not have.
commit(card.id, destination.id, Math.min(index, occupants))
}
return (
<div
ref={boardNode}
data-slot="kanban-board"
data-size={size}
role="group"
aria-label={ariaLabel}
className={cn("group/kanban-board flex w-full min-w-0 flex-col gap-2", className)}
{...props}
>
<div
data-slot="kanban-board-columns"
className="flex items-start gap-3 overflow-x-auto pb-1 [scrollbar-width:thin]"
>
{columns.map((spec) => {
const members = cards.filter((card) => card.column === spec.id)
return (
<KanbanColumnShell
key={spec.id}
ref={(node: HTMLDivElement | null) => {
columnNodes.current.set(spec.id, node)
}}
data-column-id={spec.id}
title={spec.title}
count={members.length}
limit={spec.limit}
meta={spec.meta}
dropTarget={drop?.column === spec.id}
emptyMessage={emptyMessage}
headingLevel={headingLevel}
>
{members.map((card) => (
<KanbanCardShell
key={card.id}
ref={(node: HTMLLIElement | null) => {
cardNodes.current.set(card.id, node)
}}
data-card-id={card.id}
dragging={dragging === card.id}
grabbed={held?.id === card.id}
aria-describedby={hintId}
onPointerDown={(event) => handlePointerDown(event, card.id)}
onKeyDown={(event) => handleKeyDown(event, card)}
>
{renderCard(card)}
</KanbanCardShell>
))}
</KanbanColumnShell>
)
})}
</div>
<p id={hintId} className="sr-only">
Arrow keys move this card; space picks it up and drops it; escape puts it back.
</p>
<div data-slot="kanban-board-status" role="status" aria-live="polite" className="sr-only">
{announcement}
</div>
</div>
)
}
export { KanbanBoard, KanbanCardShell, KanbanColumnShell }import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export type KanbanCardShellProps = React.ComponentProps<"li"> & {
/** Under the pointer right now: lifted, and faded where it came from. */
dragging?: boolean
/** Held by the keyboard: the reader has picked it up and not put it down. */
grabbed?: boolean
}
/**
* One card on the board. It is the drag surface as well as the card, so a
* reader grabs it anywhere rather than hunting for a handle — the grip in the
* corner says so without being the only place that works. `touch-none` is what
* lets a finger drag it: without it the browser scrolls the column instead.
*/
function KanbanCardShell({
className,
dragging = false,
grabbed = false,
children,
...props
}: KanbanCardShellProps) {
return (
<li
data-slot="kanban-card"
data-dragging={dragging || undefined}
data-grabbed={grabbed || undefined}
tabIndex={0}
aria-roledescription="Draggable card"
className={cn(
"group/kanban-card relative cursor-grab touch-none rounded-lg bg-card p-3 text-sm shadow-(--shadow-sheet) ring-1 ring-border select-none",
"transition-[box-shadow,opacity] duration-(--duration-fast) ease-(--ease-standard) focus-ring",
"group-data-[size=sm]/kanban-board:p-2.5 group-data-[size=sm]/kanban-board:text-xs",
// Lifted while it is held, either way it was picked up.
"data-dragging:cursor-grabbing data-dragging:opacity-60 data-dragging:shadow-(--shadow-float)",
"data-grabbed:bg-brand-muted data-grabbed:shadow-(--shadow-float)",
className
)}
{...props}
>
<GripVerticalIcon
aria-hidden="true"
className="pointer-events-none absolute top-2.5 end-1 size-3.5 text-faint-foreground opacity-0 transition-opacity duration-(--duration-fast) group-hover/kanban-card:opacity-100 group-focus-visible/kanban-card:opacity-100"
/>
<div data-slot="kanban-card-body" className="min-w-0 pe-3">
{children}
</div>
</li>
)
}
export { KanbanCardShell }import * as React from "react"
import { cn } from "@/lib/utils"
export type KanbanColumnShellProps = React.ComponentProps<"div"> & {
title: React.ReactNode
count: number
/** The WIP limit, when the column has one. */
limit?: number
/** A second line under the title — a total, a share, a sum of what is in it. */
meta?: React.ReactNode
/** The card being dragged would land here if the pointer were released now. */
dropTarget?: boolean
/** What an empty column says instead of showing nothing at all. */
emptyMessage?: React.ReactNode
/**
* The heading level the title is set at. A board under a card's h2 keeps
* the default; a page whose h1 sits directly over the board passes 2, or
* its outline skips from h1 to h3.
*/
headingLevel?: 2 | 3 | 4
}
/**
* One column: a heading with its count, then the cards. `role="group"` rather
* than a landmark — five regions on one board is five landmarks a screen
* reader has to walk past, and a column is a grouping, not a section of the
* page.
*/
function KanbanColumnShell({
className,
title,
count,
limit,
meta,
dropTarget = false,
emptyMessage = "Nothing here",
headingLevel = 3,
children,
...props
}: KanbanColumnShellProps) {
const headingId = React.useId()
const over = limit !== undefined && count > limit
const Heading = `h${headingLevel}` as const
return (
<div
data-slot="kanban-column"
data-over-limit={over || undefined}
data-drop-target={dropTarget || undefined}
role="group"
aria-labelledby={headingId}
className={cn(
"flex min-w-64 flex-1 shrink-0 flex-col gap-2 rounded-xl bg-secondary/60 p-2",
"transition-colors duration-(--duration-fast) ease-(--ease-standard)",
"data-drop-target:bg-brand-muted",
className
)}
{...props}
>
{/* `relative` because of the visually-hidden note below: sr-only is
`position: absolute`, and without a containing block inside this
column it would resolve against the page, escape the horizontal
scroller and stretch the document sideways at 390. */}
<div
data-slot="kanban-column-header"
className="relative flex items-start justify-between gap-2 px-1 pt-0.5"
>
<div className="min-w-0">
<Heading id={headingId} data-slot="kanban-column-title" className="type-label truncate">
{title}
</Heading>
{meta ? (
<p data-slot="kanban-column-meta" className="type-eyebrow truncate text-faint-foreground">
{meta}
</p>
) : null}
</div>
<span
data-slot="kanban-column-count"
className={cn(
"type-eyebrow shrink-0 rounded-full px-1.5 py-0.5 tabular-nums",
// The tone's own ink on its tint, as StatusBadge pairs them;
// -foreground is for a solid fill and is unreadable here.
over ? "bg-warning-muted text-warning" : "text-faint-foreground"
)}
>
{limit === undefined ? count : `${count} / ${limit}`}
</span>
{over ? <span className="sr-only">Over the limit of {limit}</span> : null}
</div>
<ul data-slot="kanban-column-cards" className="flex min-h-16 flex-col gap-2">
{children}
</ul>
{count === 0 ? (
<p
data-slot="kanban-column-empty"
className="rounded-lg border border-dashed border-input px-3 py-4 text-center text-xs text-muted-foreground"
>
{emptyMessage}
</p>
) : null}
</div>
)
}
export { KanbanColumnShell }/**
* Where a drag would land, measured off the DOM.
*
* A board is a picture, and a drop is a question about that picture: which
* column is the pointer over, and how many cards has it passed. Both answers
* come from `getBoundingClientRect`, so the board never has to model its own
* layout — which is what lets a column wrap, scroll, or change width without
* any of this knowing.
*/
/** How far the pointer has to travel before a press becomes a drag. */
export const DRAG_THRESHOLD = 5
/**
* How far outside the board's own rect a pointer may still be and count as
* "over" it — a little slack for the last column's own edge and for
* measurement noise, not enough to reach the header or the sidebar above it.
*/
export const BOARD_TOLERANCE = 24
export type DropTarget = { column: string; index: number }
/** The text a card shows, on one line, short enough to read out. */
export function cardText(node: HTMLElement | null | undefined): string {
const text = (node?.textContent ?? "").replace(/\s+/g, " ").trim()
return text.length > 80 ? `${text.slice(0, 79)}…` : text
}
export type DropQuery = {
/** The column ids, in the order they are drawn. */
columns: string[]
/** Every card's column, keyed by card id, in the order they are drawn. */
cards: { id: string; column: string }[]
columnNodes: Map<string, HTMLElement | null>
cardNodes: Map<string, HTMLElement | null>
/** The board's own rect; a pointer outside it (past BOARD_TOLERANCE) drops nothing. */
board: HTMLElement | null
/** The card being dragged; it is not counted among what the pointer passes. */
dragId: string
x: number
y: number
}
/**
* The column under `x` — or, past either end of the board, the nearest one —
* and the slot at `y`: before the first card whose midpoint the pointer has
* not reached, and at the end when it has passed all of them. `null` once the
* pointer is outside the board itself — the "nearest column" fallback is for
* a release that overshot the columns, not one over the header or a sidebar.
*/
export function dropTargetAt({
columns,
cards,
columnNodes,
cardNodes,
board,
dragId,
x,
y,
}: DropQuery): DropTarget | null {
if (board) {
const box = board.getBoundingClientRect()
const outside =
x < box.left - BOARD_TOLERANCE ||
x > box.right + BOARD_TOLERANCE ||
y < box.top - BOARD_TOLERANCE ||
y > box.bottom + BOARD_TOLERANCE
if (outside) return null
}
let column: string | null = null
let nearest = Number.POSITIVE_INFINITY
for (const id of columns) {
const node = columnNodes.get(id)
if (!node) continue
const box = node.getBoundingClientRect()
const distance = x < box.left ? box.left - x : x > box.right ? x - box.right : 0
if (distance < nearest) {
nearest = distance
column = id
}
}
if (column === null) return null
const members = cards.filter((card) => card.column === column && card.id !== dragId)
let index = members.length
for (const [position, member] of members.entries()) {
const node = cardNodes.get(member.id)
if (!node) continue
const box = node.getBoundingClientRect()
if (y < box.top + box.height / 2) {
index = position
break
}
}
return { column, index }
}