一键导入
kanban-dnd
Build world-class kanban board drag-and-drop with @dnd-kit. Linear-quality UX with proper collision detection, smooth animations, and visual feedback
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Build world-class kanban board drag-and-drop with @dnd-kit. Linear-quality UX with proper collision detection, smooth animations, and visual feedback
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
write high-converting Meta ad creative text for cold or warm audiences. Use when creating, rewriting, critiquing, or improving Meta/Facebook/Instagram ad copy, including primary text, headlines, descriptions, hooks, creative copy, and ad concept messaging. Especially useful when ads need to be clear, specific, scannable, and conversion-focused rather than vague, clever, or overly brand-like.
AI-powered SEO article production pipeline. Researches keywords, writes high-quality articles with branded hero images and inline visuals, scores quality at 110 pts, processes images via script, and publishes at scale. Configurable for any company via config/ subfolder. Currently configured for: Blink (blink.new). Commands: "run seo", "write seo articles", "seo run", "run seo 8 articles", "run seo b7 sprint", "run seo [market] focus".
AI-powered SEO article production pipeline. Researches keywords, writes high-quality articles with branded hero images and inline visuals, scores quality at 110 pts, processes images via script, and publishes at scale. Configurable for any company via config/ subfolder. Currently configured for: Blink (blink.new). Commands: "run seo", "write seo articles", "seo run", "run seo 8 articles", "run seo b7 sprint", "run seo [market] focus".
Comprehensive UI/UX design principles covering affordances, visual hierarchy, grids, typography, color theory, dark mode, shadows, icons, buttons, feedback states, micro-interactions, and overlays. Use when designing UI components, reviewing design quality, building new screens, giving design feedback, writing design specs for AI coding assistants, or teaching product/engineering teams correct design vocabulary.
Design entire feature flows end-to-end with user needs, user stories, and ASCII prototypes. Explores codebase to understand existing patterns, reuses existing components and modals, asks alignment questions, then designs world-class Linear/Stripe/Vercel-quality UI/UX. Writes final PRD into .todo/[feat-name]/PRD.md. Use when designing a new feature, flow, or screen — especially before implementation.
Analyze any public video URL (Loom, YouTube, etc.) and produce a vivid, engineer-grade report of what was shown and said. Use when a user shares a Loom, YouTube, or other video URL and wants it analyzed, summarized, transcribed, or understood by an AI — without needing to watch it themselves.
| name | kanban-dnd |
| description | Build world-class kanban board drag-and-drop with @dnd-kit. Linear-quality UX with proper collision detection, smooth animations, and visual feedback |
Use when:
| 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 |
bun add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/utilities
┌─────────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────────┘
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;
};
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,
},
});
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"
>
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]
);
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>
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.
// 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}
/>
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>
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()];
}
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.
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);
}, []);
// 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 during any drag
isDragging && "bg-muted/30"
// Column being hovered
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"
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>
// WRONG - useSortable is for within-list reordering
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });
// CORRECT - useDraggable for simple drag to droppable
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.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!
};
// CORRECT - Handle both column and card drop targets
const targetColumnId = findTargetColumnId(over.id);
// WRONG - Causes fly-out effect on drop
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={{ duration: 200, easing: "ease-out" }}
>
// CORRECT - Instant disappear on drop
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={null}
>
// WRONG - Creates offset issues, worse UX
<Card>
<div {...listeners}>
<GripIcon /> {/* Only this area is draggable */}
</div>
<Content />
</Card>
// 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 } })
// WRONG - won't match a cache fetched with extra params (limit/sort/…)
const items = queryClient.getQueryData(itemKeys.list({ columnId })); // undefined
See pattern #9 (getColumnItems) — silently-failing reorders are almost always this.
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/SpaceAfter 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 } })
);
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 });
},
});
}
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
| 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 |
@dnd-kit/core, @dnd-kit/modifiers, @dnd-kit/utilitiesdistance activation constraint, never delay (instant drag)MouseSensor (distance) + TouchSensor (delay) if the board must scroll on mobileuseDroppable with data.type: "column" (or useSortable if columns reorder)useDraggable for cross-column only; useSortable + SortableContext if reordering within a columnuseSortable column does NOT also register a separate useDroppable (duplicate id)findTargetColumnId resolves both column and card drop targetsonDragEnd (not a reconstructed key)snapCenterToCursor modifierdropAnimation={null} (else the card "reverts" then snaps on release)isOver), not a persistent gaptouch-none select-none cursor-grab classesisOver and isDragging states