关于 kanban-dnd
kanban-dnd 是使用 @dnd-kit 构建看板和流程板拖放交互的技能,重点处理跨列移动、碰撞检测及桌面和触屏操作细节。
主要功能
- 01优先识别列的碰撞检测
- 02支持卡片跨列移动
- 03使拖动覆盖层对齐光标
- 04配置鼠标与触屏传感器
- 05支持可撤销的拖放操作
适用场景
- 01构建可拖动卡片的看板
- 02避免跨列移动时误判放置目标
- 03实现即时鼠标拖动并兼容触屏滚动
- 04为移动和排序添加撤销与重做
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ blink-new/ claude --skill kanban-dnd安装后在 Claude Code 中输入/kanban-dnd确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/components/kanban-board.tsxkanban-card.tsxkanban-column.tsxassets/lib/collision-detection.ts详细介绍
何时使用此技能
适用于:
- 构建支持拖放的看板/流水线面板
- 实现卡片在列之间移动
- 需要正确的碰撞检测,并优先匹配列而非卡片
- 希望实现类似 Linear/Notion 的拖拽体验,叠加层跟随光标移动
技术栈
| 软件包 | 版本 | 用途 |
|---|---|---|
@dnd-kit/core | ^6.x | 核心 DnD 上下文和钩子 |
@dnd-kit/modifiers | ^9.x | 光标吸附修饰符 |
@dnd-kit/utilities | ^3.x | CSS 变换工具 |
安装
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) │
└─────────────────────────────────────────────────────────────┘
关键模式
1. 自定义碰撞检测(最重要)
问题: 默认碰撞检测在放下卡片时,可能返回卡片 ID,而非列 ID。
解决方案: 使用自定义碰撞检测,优先匹配可放置的列:
// 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. 使用数据类型设置列
列必须包含 data.type,以便碰撞检测进行筛选:
const { setNodeRef } = useDroppable({
id: column.id,
data: {
type: "column", // CRITICAL: Enables collision filtering
columnId: column.id,
},
});
3. 使用 useDraggable 设置卡片(而非 useSortable)
如果需要在不同列之间移动卡片,而不需要在列内重新排序,请使用 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. 将放置目标解析为列 ID
同时处理放置到列和放置到卡片的情况:
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
问题: 默认的 DragOverlay 与光标之间会有偏移。
解决方案: 使用 snapCenterToCursor 修饰符,并禁用放置动画:
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. 传感器配置——拖动即时响应,兼顾移动端滚动
使用 distance 约束,绝不要使用 delay。 distance 约束会在指针移动几 px 时立即开始拖动——拖动体验响应迅速,普通点击(不移动)仍会触发卡片的 onClick。delay 约束会迫使用户在卡片变为可拖动状态前按住不放,而且如果在延迟结束前快速滑动,拖动会被完全取消——卡片根本不会移动。这是一个非常常见的“必须先按住才能拖动”的问题。
// ✅ 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,
})
);
如果不需要支持移动端滚动,单独使用带有
{ distance: 4 } 的 PointerSensor 即可,响应同样即时。关键规则是:使用 distance,而不是 delay。
7. 视觉反馈状态
// 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. 列内重新排序与可排序列(高级)
上面的模式介绍了如何使用 useDraggable 在列之间移动项目。如果你还需要在列内重新排列卡片,和/或重新排列列本身,请改用 useSortable + 嵌套的 SortableContext(全部放在同一个 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>
在 onDragEnd 中,根据 active.data.current.type("column" 或 "card")分支,以判断你是在重新排列列还是移动卡片。
注意事项——可排序列本身也是放置目标;不要重复注册。
useSortable 已经让节点具备可放置能力。如果你还在同一列上调用 useDroppable({ id: column.id }),就会注册两个 ID 相同的可放置项,从而破坏碰撞检测(卡片将无法可靠地放置)。只使用 useSortable 即可——它会提供 setNodeRef(放置目标)、attributes/listeners(拖动手柄)和 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 })
放置指示器:在悬停的卡片上显示线条,而不是持续显示空隙。 在指针当前悬停的那张卡片(isOver)上绘制一条细插入线,并根据被拖动项目的索引与该卡片的索引,决定将线放在卡片上方还是下方。不要只依赖可排序项目之间不断移动的空隙——清晰的线条易于辨认,而且只会出现在相关位置。
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")}
“卡片先复位,再在松开时跳到位”闪动,几乎总是因为 DragOverlay 缺少 dropAnimation={null}(参见 #5)。默认的放置动画会先让浮动副本飞回原始位置,然后列表才会稳定到新的顺序。务必设置 dropAnimation={null}。
悬停时显示拖动手柄——但要收缩它的_宽度_,而不只是透明度。 常驻的抓握图标(⠿)会造成视觉干扰,所以只在悬停时显示它,并在列根节点上使用 group/col。问题在于:单独使用 opacity-0 group-hover/col:opacity-100 仍会让手柄占据布局空间——它会保留自身宽度,旁边的 flex gap 也会保留——因此即使抓握图标不可见,标题仍会因为幽灵间距而一直向内缩进。(绝对定位手柄只会转移问题:现在它会与标题重叠,或超出列的边缘。)
你真正想要的是:
idle → [no space][Title] handle reserves nothing, title flush
hover → [⠿ space ][Title] handle expands in-flow, title slides right
实现方法是:空闲时将手柄收缩至零宽度,悬停时再展开;同时移除标题栏上的 flex gap(即使相邻子项宽度为零,gap 仍会生效)——让手柄自身控制外边距,这样它就只会在悬停时出现:
{/* 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>
通过动画过渡 w-0 → w-4(加上 mr),标题会随着抓握图标淡入而平滑地向右滑动,空闲状态则会与下方的卡片完全对齐。
拖动列时,在覆盖层中渲染整个列——卡片使用_静态_副本。 覆盖层应预览标题栏和卡片,让用户拖动的是完整副本,而不只是标题。但不要在覆盖层中渲染真实的 useSortable 卡片组件:卡片仍挂载在列表中,因此会注册重复的可排序 ID,并破坏测量和碰撞检测。请渲染轻量的静态副本(外观与卡片相同的普通 div),并限制其高度不超过列的最大高度:
<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. 在 onDragEnd 中从缓存读取列项目(注意事项)
为了计算放置时的新顺序,你通常会从 React Query 缓存中读取列的项目。不要重建完全相同的查询键——该列很可能是使用额外参数(limit、排序、includeX 等)获取的,手动构建的键将无法匹配,因此 getQueryData 会返回 undefined,重新排序也就不会有任何效果(这是“有些列可以拖动,有些列不行”这一经典问题的成因)。请以不依赖缓存键的方式读取——扫描所有列表查询,并保留作用域属于该列的查询:
// ❌ 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. 可撤销的拖动操作
要让拖动操作支持 Cmd+Z 撤销,每次成功移动或重新排序时都要记录一个逆向命令。一个容易忽略的细节是:**跨列移动必须恢复所有受影响分组之前的顺序——包括源列和目标列——而不只是目标列。**如果只恢复目标列,卡片仍会留在新列中,因此撤销操作看起来什么也没做。
// 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 }),
});
在变更钩子中记录,而不是在每个调用位置分别记录,这样所有入口(拖动、键盘快捷键、菜单)都能通过同一个位置支持撤销。撤销/重做时直接调用 API(不要调用记录钩子),以免应用历史记录时又记录新的条目。
完整事件处理器
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"
空列状态
仅在拖动进行中时渲染放置占位符。空列处于空闲状态时不应占用任何空间——不要预留一个常驻的“拖到这里”区域;列自身的“添加”入口就足够了。持续显示占位符很碍眼,会让空闲的看板显得杂乱。
{/* ✅ 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>
应避免的常见错误
❌ 使用 useSortable 进行跨列移动
// WRONG - useSortable is for within-list reordering
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });
✅ 使用 useDraggable 进行跨列移动
// CORRECT - useDraggable for simple drag to droppable
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });
❌ 假设 over.id 始终是列 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!
};
✅ 将 over.id 解析为列 ID
// CORRECT - Handle both column and card drop targets
const targetColumnId = findTargetColumnId(over.id);
❌ 使用 snapCenterToCursor 时启用放置动画
// 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 的激活方式
// WRONG - the card only drags after a hold, and a quick flick is cancelled
useSensor(PointerSensor, { activationConstraint: { delay: 100, tolerance: 5 } })
✅ 在鼠标上使用基于 distance 的激活方式
// 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
✅ 通过匹配所有列表查询中的列字段来读取缓存
参见模式 #9(getColumnItems)——重新排序无声失败,几乎总是由这个问题导致。
❌ 在 useSortable 列上单独注册 useDroppable
// WRONG - two droppables share column.id, breaking collision detection
useSortable({ id: column.id, ... });
useDroppable({ id: column.id }); // duplicate id!
❌ 在也会处理 Enter/Space 的可聚焦卡片上使用 KeyboardSensor
鼠标拖动后,dnd-kit 会将 DOM 焦点留在被拖动的卡片上。KeyboardSensor 的默认激活键是空格/Enter,因此它会将焦点所在卡片上的下一个空格/Enter 视为“开始拖动”——按 Enter 执行其他快捷操作时,会意外地再次抓起卡片并使其进入拖动状态。如果卡片可以聚焦,而且应用会在其他地方绑定 Enter/Space,请移除 KeyboardSensor,改用显式快捷键提供键盘重新排序功能(上移/下移、延后等),或者重新映射它的 keyboardCodes。
// ✅ Mouse + touch only; no accidental Enter/Space drags
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
);
使用 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 });
},
});
}
文件结构
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
相关资源文件
| 资源 | 描述 |
|---|---|
assets/lib/collision-detection.ts | 列优先碰撞检测 |
assets/components/kanban-board.tsx | 完整的看板实现 |
assets/components/kanban-column.tsx | 带视觉反馈的可放置列 |
assets/components/kanban-card.tsx | 可拖动卡片组件 |
检查清单
- 已安装
@dnd-kit/core、@dnd-kit/modifiers、@dnd-kit/utilities - 传感器使用
distance激活约束,绝不使用delay(立即拖动) - 如果看板必须支持在移动设备上滚动,则使用
MouseSensor(distance)+TouchSensor(delay) - 自定义碰撞检测优先匹配列
- 列使用
useDroppable,并设置data.type: "column"(如果列可重新排序,则使用useSortable) - 卡片仅在跨列拖动时使用
useDraggable;如果支持列内重新排序,则使用useSortable+SortableContext -
useSortable列不会同时注册单独的useDroppable(避免 ID 重复) -
findTargetColumnId能解析列和卡片拖放目标 - 在
onDragEnd中,列项以不依赖缓存键的方式从缓存读取(而不是重建缓存键) - DragOverlay 使用
snapCenterToCursormodifier - DragOverlay 设置
dropAnimation={null}(否则松开时卡片会先“复位”,再跳到目标位置) - 列内拖放时,在悬停卡片上显示插入线(
isOver),而非持久占位间隙 - 空列占位符仅在拖动期间渲染——空闲状态下的空列不占空间
- DragOverlay 包装元素的宽度固定,并与卡片宽度一致
- 卡片使用
touch-none select-none cursor-grab类 - 为
isOver和isDragging状态提供视觉反馈 - 出错时回滚乐观更新
- 可撤销的移动会恢复所有受影响分组之前的顺序(源分组 + 目标分组)