When to Use This Skill
Use when:
- Building a kanban/pipeline board with drag-and-drop
- Implementing card movement between columns
- Need proper collision detection that prioritizes columns over cards
- Want Linear/Notion-style drag UX with cursor-following overlay
Tech Stack
| Package | Version | Purpose |
|---|---|---|
@dnd-kit/core |
^6.x | Core DnD context and hooks |
@dnd-kit/modifiers |
^9.x | Cursor snapping modifiers |
@dnd-kit/utilities |
^3.x | CSS transform utilities |
Installation
bun add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/utilities
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ DndContext (sensors, collision detection, event handlers) │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Column │ │ Column │ │ Column │ │ Column │ │
│ │(droppa- │ │(droppa- │ │(droppa- │ │(droppa- │ │
│ │ ble) │ │ ble) │ │ ble) │ │ ble) │ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ │ │
│ │ │Card │ │ │ │Card │ │ │ │Card │ │ │ Empty │ │
│ │ │drag │ │ │ │drag │ │ │ │drag │ │ │ │ │
│ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │ │ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │ │ │ │ │
│ │ │Card │ │ │ │Card │ │ │ │ │ │ │
│ │ └─────┘ │ │ └─────┘ │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
├─────────────────────────────────────────────────────────────┤
│ DragOverlay (follows cursor with snapCenterToCursor) │
└─────────────────────────────────────────────────────────────┘
Critical Patterns
1. Custom Collision Detection (MOST IMPORTANT)
Problem: Default collision detection can return card IDs when dropping on a card instead of the column ID.
Solution: Custom collision detection that prioritizes column droppables:
// lib/dnd/collision-detection.ts
import {
pointerWithin,
rectIntersection,
type CollisionDetection,
} from "@dnd-kit/core";
export const columnPriorityCollision: CollisionDetection = (args) => {
// First, check pointer intersections
const pointerCollisions = pointerWithin(args);
// Filter to only column collisions (columns have data.type === 'column')
const columnCollisions = pointerCollisions.filter(
(collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
);
if (columnCollisions.length > 0) {
return columnCollisions;
}
// Fallback to rect intersection
const rectCollisions = rectIntersection(args);
const columnRectCollisions = rectCollisions.filter(
(collision) => collision.data?.droppableContainer?.data?.current?.type === "column"
);
if (columnRectCollisions.length > 0) {
return columnRectCollisions;
}
return rectCollisions;
};
2. Column Setup with Data Type
Columns must include data.type for collision detection filtering:
const { setNodeRef } = useDroppable({
id: column.id,
data: {
type: "column", // CRITICAL: Enables collision filtering
columnId: column.id,
},
});
3. Card Setup with useDraggable (NOT useSortable)
For cross-column moves without within-column reordering, use useDraggable:
const {
attributes,
listeners,
setNodeRef,
isDragging,
} = useDraggable({
id: card.id,
data: {
type: "card",
card,
},
});
// Apply to entire card for drag-anywhere behavior
<Card
ref={setNodeRef}
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none select-none"
>
4. Resolving Drop Target to Column ID
Handle both column drops and card drops:
const findTargetColumnId = useCallback(
(overId: UniqueIdentifier | undefined): string | null => {
if (!overId) return null;
const overIdStr = String(overId);
// Check if it's a column ID directly
if (columnIds.has(overIdStr)) {
return overIdStr;
}
// Otherwise it's a card ID - find which column that card is in
const columnId = cardColumnMap.get(overIdStr);
return columnId || null;
},
[columnIds, cardColumnMap]
);
5. DragOverlay with Cursor Snapping
Problem: Default DragOverlay appears offset from cursor.
Solution: Use snapCenterToCursor modifier and disable drop animation:
import { snapCenterToCursor } from "@dnd-kit/modifiers";
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={null} // Prevents fly-out effect on drop
>
{activeCard ? (
<div className="w-[272px] pointer-events-none">
<Card card={activeCard} isDragging />
</div>
) : null}
</DragOverlay>
6. Sensor Configuration — instant drag, mobile-scroll friendly
Use a distance constraint, never a delay. A distance constraint starts
the drag the instant the pointer moves a few px — dragging feels immediate, and
a plain click (no movement) still falls through to the card's onClick. A
delay constraint forces the user to hold before the card becomes draggable,
and a quick flick before the delay elapses cancels the drag entirely — the
card won't move at all. This is a very common "I can't drag unless I hold first"
bug.
// ✅ Instant on mouse, still scrolls on touch.
// MouseSensor (mouse only) + TouchSensor (touch only) beats a single
// PointerSensor, which can't both start instantly on mouse AND let a finger
// swipe scroll the board on mobile.
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: { distance: 4 }, // drag starts after ~4px, no hold
}),
useSensor(TouchSensor, {
// A short press-and-hold on touch so a vertical swipe scrolls the board
// instead of grabbing a card. Touch is the one place a small delay is right.
activationConstraint: { delay: 200, tolerance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
If you don't need mobile scrolling, a single PointerSensor with
{ distance: 4 } is fine and also instant. The rule that matters: distance,
not delay.
7. Visual Feedback States
// Board tracks active states
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeOverColumn, setActiveOverColumn] = useState<string | null>(null);
// Pass to columns for visual feedback
<Column
isOver={activeOverColumn === column.id}
isDragging={!!activeCard}
/>
8. Within-Column Reordering & Sortable Columns (advanced)
The patterns above cover cross-column moves with useDraggable. If you also
need to reorder cards within a column and/or reorder the columns
themselves, switch to useSortable + nested SortableContext (all inside one
DndContext):
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
{columns.map((col) => <Column key={col.id} column={col} />)}
</SortableContext>
// Inside each Column, its cards get their own vertical context:
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
{cards.map((c) => <Card key={c.id} card={c} />)}
</SortableContext>
In onDragEnd, branch on active.data.current.type ("column" vs "card") to
decide whether you're reordering columns or moving a card.
Gotcha — a sortable column is ALSO a drop target; don't register both.
useSortable already makes the node droppable. If you additionally call
useDroppable({ id: column.id }) on the same column you register two
droppables with the same id, which corrupts collision detection (cards stop
dropping reliably). Use useSortable alone — it gives you setNodeRef (drop
target), attributes/listeners (drag handle), and isOver:
// ✅ One registration is both the card drop target and the column drag source
const { setNodeRef, attributes, listeners, transform, transition, isOver } =
useSortable({ id: column.id, data: { type: "column", columnId: column.id } });
// ❌ Do NOT also do: useDroppable({ id: column.id })
Drop indicator: a line on the hovered card, not a persistent gap. Render a
thin insertion line on the single card the pointer is over (isOver), placed
above or below it based on the dragged item's index vs this card's index. Don't
rely on the sortable's shifting gap alone — a crisp line reads far better and
appears only where it's relevant.
const { isOver, active, index } = useSortable({ id: card.id, data: { ... } });
const showIndicator = isOver && active?.id !== card.id;
let above = false, below = false;
if (showIndicator) {
if (active?.data.current?.columnId !== card.columnId) {
above = true; // cross-column: insert before the hovered card
} else {
const activeIndex = active?.data.current?.sortable?.index ?? -1;
above = activeIndex > index; // dragging up → line above
below = activeIndex < index && activeIndex !== -1; // dragging down → below
}
}
// Card root must be `relative`. Lines are absolutely positioned so DOM order
// doesn't matter:
{above && <div className="absolute -top-1 left-0 right-0 h-0.5 rounded-full bg-primary z-10" />}
{below && <div className="absolute -bottom-1 left-0 right-0 h-0.5 rounded-full bg-primary z-10" />}
// Dragged original is just dimmed in place:
className={cn("relative", isDragging && "opacity-30 z-50")}
The "card reverts then snaps into place on release" flash is almost always
a missing dropAnimation={null} on the DragOverlay (see #5). The default drop
animation flies the floating clone back to the original slot before the list
settles into the new order. Always set dropAnimation={null}.
Reveal drag handles on hover — but collapse their width, not just their
opacity. A persistent grip (⠿) is visual noise, so show it only on hover with
group/col on the column root. The trap: opacity-0 group-hover/col:opacity-100
alone still lays the handle out — it keeps its width and the flex gap next to
it — so the title sits permanently indented by a phantom gutter even while the
grip is invisible. (Absolutely-positioning the handle instead just moves the
problem: now it overlaps the title or hangs off the column edge.)
What you actually want:
idle → [no space][Title] handle reserves nothing, title flush
hover → [⠿ space ][Title] handle expands in-flow, title slides right
Achieve it by collapsing the handle to zero width when idle and expanding it
on hover, and drop the flex gap on the header (a gap is applied even
beside a zero-width child) — let the handle own its margin so it only appears on
hover:
{/* header: NO `gap` — the handle manages its own spacing */}
<div className="flex items-center px-1">
<button
{...attributes} {...listeners}
className="flex w-0 shrink-0 items-center overflow-hidden opacity-0
transition-all duration-150
group-hover/col:mr-1 group-hover/col:w-4 group-hover/col:opacity-100
cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical className="h-4 w-4 shrink-0" />
</button>
<span className="font-semibold">{name}</span>
<span className="ml-2 …">{count}</span> {/* space the rest explicitly */}
</div>
Transitioning w-0 → w-4 (plus mr) makes the title slide right smoothly as
the grip fades in, with the idle state perfectly flush against the cards below.
Dragging a column? Render the WHOLE column in the overlay — with static card
clones. The overlay should preview the header and its cards so the user
drags a faithful copy, not just the title. But do not render your real
useSortable card component inside the overlay: the cards are still mounted in
the list, so you'd register duplicate sortable ids and corrupt measuring/
collision. Render lightweight static clones (plain divs with the card's look),
clipped to the column's max height:
<DragOverlay dropAnimation={null}>
{activeColumn ? (
<div className="w-[272px] rotate-[2deg] rounded-xl border bg-muted/95 p-2.5 shadow-xl ...">
<Header name={activeColumn.name} count={cards.length} />
<div className="flex max-h-[440px] flex-col gap-2 overflow-hidden">
{cards.map((c) => (
<div key={c.id} className="rounded-lg border bg-card px-3 py-2.5">
{c.title} {/* static clone — NOT <SortableCard/> */}
</div>
))}
</div>
</div>
) : activeCard ? (
<Card card={activeCard} isDragging />
) : null}
</DragOverlay>
9. Reading column items from the cache in onDragEnd (gotcha)
To compute the new order on drop you often read a column's items out of a React
Query cache. Do not reconstruct the exact query key — the column was likely
fetched with extra params (limit, sort, includeX, …) and a hand-built key
won't match, so getQueryData returns undefined and the reorder silently
does nothing (a classic "drag works in some columns but not others" bug). Read
cache-key-agnostically — scan every list query and keep the ones scoped to that
column:
// ❌ Misses the cache if the real query was { columnId, limit: 200 }
const items = queryClient.getQueryData(itemKeys.list({ columnId }));
// ✅ Match by the column field, ignore other filter params
function getColumnItems(columnId: string): Item[] {
const byId = new Map<string, Item>();
for (const [key, data] of queryClient.getQueriesData<Item[]>({
queryKey: itemKeys.lists(),
})) {
const filters = (key as unknown[])[2] as { columnId?: string } | undefined;
if (data && filters?.columnId === columnId) {
for (const it of data) if (!byId.has(it.id)) byId.set(it.id, it);
}
}
return [...byId.values()];
}
10. Undoable drag operations
To make drags Cmd+Z-able, record an inverse command on each successful
move/reorder. The non-obvious part: a cross-column move must restore the
previous order of EVERY bucket it touched — the source AND the destination —
not just the destination. Restoring only the destination leaves the card in
its new column, so undo appears to do nothing.
// Capture the *pre-move* order of every affected bucket: the destination
// plus each source column a card moved out of.
record({
label: "Move card",
undo: async () => {
for (const [columnId, order] of Object.entries(prevOrders)) {
// Restoring a source column (whose saved order still includes the moved
// card) sends the card home; restoring the destination drops it.
await api.reorder({ columnId, itemIds: order });
}
},
redo: async () => api.reorder({ columnId: destColumnId, itemIds: newOrder }),
});
Record inside the mutation hook, not each call site, so every entry point (drag, keyboard shortcut, menu) becomes undoable through one place. Have undo/redo call the API directly (not the recording hook) so applying history never records new entries.
Complete Event Handlers
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
const card = cards?.find((c) => c.id === active.id);
if (card) {
setActiveCard(card);
setActiveOverColumn(card.columnId);
}
}, [cards]);
const handleDragOver = useCallback((event: DragOverEvent) => {
const { over } = event;
if (!over) {
setActiveOverColumn(null);
return;
}
const targetColumnId = findTargetColumnId(over.id);
setActiveOverColumn(targetColumnId);
}, [findTargetColumnId]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveCard(null);
setActiveOverColumn(null);
if (!over) return;
const cardId = String(active.id);
const targetColumnId = findTargetColumnId(over.id);
if (!targetColumnId) return;
const card = cards?.find((c) => c.id === cardId);
if (!card || card.columnId === targetColumnId) return;
// Optimistic update via mutation
updateCard.mutate({
cardId,
data: { columnId: targetColumnId },
});
}, [cards, findTargetColumnId, updateCard]);
const handleDragCancel = useCallback(() => {
setActiveCard(null);
setActiveOverColumn(null);
}, []);
Styling Patterns
Card States
// Original card being dragged
isCurrentlyDragging && "opacity-30"
// DragOverlay card
isDragging && "shadow-xl ring-2 ring-primary/20 rotate-[1deg] cursor-grabbing"
// Entire card is draggable
"cursor-grab active:cursor-grabbing touch-none select-none"
Column States
// Column during any drag
isDragging && "bg-muted/30"
// Column being hovered
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"
Empty Column State
Render the drop placeholder only during an active drag. An idle empty column must take up no space — don't reserve a permanent "Drag here" box; the column's own "add" affordance is enough. Showing a persistent placeholder is a common eyesore that makes idle boards look cluttered.
{/* ✅ Only while a card is being dragged. Idle empty column collapses. */}
{isDragging && (
<div className={cn(
"rounded-lg border-2 border-dashed px-2.5 py-4 text-center",
isOver ? "border-primary/40 bg-primary/5" : "border-border/50"
)}>
Drop here
</div>
)}
{/* ❌ Don't: a permanent box that reserves space when nothing is dragging */}
// <div className="border-2 border-dashed ...">Drag items here</div>
Common Mistakes to Avoid
❌ Using useSortable for cross-column moves
// WRONG - useSortable is for within-list reordering
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });
✅ Use useDraggable for cross-column moves
// CORRECT - useDraggable for simple drag to droppable
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });
❌ Assuming over.id is always a column ID
// WRONG - over.id could be a card ID if you drop on a card
const handleDragEnd = (event) => {
const newColumnId = over.id as string; // Might be a card ID!
};
✅ Resolve over.id to column ID
// CORRECT - Handle both column and card drop targets
const targetColumnId = findTargetColumnId(over.id);
❌ Using drop animation with snapCenterToCursor
// WRONG - Causes fly-out effect on drop
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={{ duration: 200, easing: "ease-out" }}
>
✅ Disable drop animation
// CORRECT - Instant disappear on drop
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={null}
>
❌ Separate drag handle
// WRONG - Creates offset issues, worse UX
<Card>
<div {...listeners}>
<GripIcon /> {/* Only this area is draggable */}
</div>
<Content />
</Card>
✅ Entire card draggable
// CORRECT - Linear-style drag anywhere
<Card {...listeners} {...attributes} className="cursor-grab">
<Content />
</Card>
❌ delay-based activation on mouse
// WRONG - the card only drags after a hold, and a quick flick is cancelled
useSensor(PointerSensor, { activationConstraint: { delay: 100, tolerance: 5 } })
✅ distance-based activation on mouse
// CORRECT - drag starts instantly on a few px of movement; click still works
useSensor(MouseSensor, { activationConstraint: { distance: 4 } })
❌ Reconstructing the exact query key to read column items on drop
// WRONG - won't match a cache fetched with extra params (limit/sort/…)
const items = queryClient.getQueryData(itemKeys.list({ columnId })); // undefined
✅ Read the cache by matching the column field across all list queries
See pattern #9 (getColumnItems) — silently-failing reorders are almost always this.
❌ Registering a separate useDroppable on a useSortable column
// WRONG - two droppables share column.id, breaking collision detection
useSortable({ id: column.id, ... });
useDroppable({ id: column.id }); // duplicate id!
❌ KeyboardSensor on focusable cards that also handle Enter/Space
After a mouse drag, dnd-kit leaves DOM focus on the dragged card. The
KeyboardSensor's default activator is Space/Enter, so it treats the next
Space/Enter on that focused card as "start dragging" — pressing Enter for some
other shortcut accidentally re-grabs the card into a dragged state. If your
cards are focusable and your app binds Enter/Space elsewhere, drop the
KeyboardSensor and provide keyboard reordering via explicit shortcuts (move
up/down, defer, …) — or remap its keyboardCodes.
// ✅ Mouse + touch only; no accidental Enter/Space drags
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
);
Optimistic Updates with React Query
export function useUpdateCard(teamId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ cardId, data }) => updateCard(teamId, cardId, data),
// Optimistic update
onMutate: async ({ cardId, data }) => {
await queryClient.cancelQueries({ queryKey: cardKeys.lists() });
const previousCards = queryClient.getQueriesData<Card[]>({
queryKey: cardKeys.lists(),
});
queryClient.setQueriesData<Card[]>(
{ queryKey: cardKeys.lists() },
(old) => old?.map((card) =>
card.id === cardId ? { ...card, ...data } : card
)
);
return { previousCards };
},
// Rollback on error
onError: (_err, _variables, context) => {
context?.previousCards?.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data);
});
},
// Refetch after settle
onSettled: () => {
queryClient.invalidateQueries({ queryKey: cardKeys.all });
},
});
}
File Structure
src/
├── components/
│ └── kanban/
│ ├── kanban-board.tsx # DndContext wrapper
│ ├── kanban-column.tsx # Droppable column
│ ├── kanban-card.tsx # Draggable card
│ └── kanban-skeleton.tsx # Loading state
├── lib/
│ └── dnd/
│ └── collision-detection.ts # Custom collision detection
└── hooks/
└── use-cards.ts # React Query with optimistic updates
Related Asset Files
| Asset | Description |
|---|---|
assets/lib/collision-detection.ts |
Column-priority collision detection |
assets/components/kanban-board.tsx |
Complete board implementation |
assets/components/kanban-column.tsx |
Droppable column with visual feedback |
assets/components/kanban-card.tsx |
Draggable card component |
Checklist
- Installed
@dnd-kit/core,@dnd-kit/modifiers,@dnd-kit/utilities - Sensors use a
distanceactivation constraint, neverdelay(instant drag) -
MouseSensor(distance) +TouchSensor(delay) if the board must scroll on mobile - Custom collision detection prioritizes columns
- Columns use
useDroppablewithdata.type: "column"(oruseSortableif columns reorder) - Cards use
useDraggablefor cross-column only;useSortable+SortableContextif reordering within a column - A
useSortablecolumn does NOT also register a separateuseDroppable(duplicate id) -
findTargetColumnIdresolves both column and card drop targets - Column items read from the cache cache-key-agnostically in
onDragEnd(not a reconstructed key) - DragOverlay uses
snapCenterToCursormodifier - DragOverlay has
dropAnimation={null}(else the card "reverts" then snaps on release) - Within-column drop shows an insertion line on the hovered card (
isOver), not a persistent gap - Empty-column drop placeholder renders only during a drag — idle empty columns take up no space
- DragOverlay wrapper has fixed width matching card width
- Cards have
touch-none select-none cursor-grabclasses - Visual feedback for
isOverandisDraggingstates - Optimistic updates with rollback on error
- Undoable moves restore every affected bucket's prior order (source + destination)