kanban-dnd
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWhen 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
适用于以下场景:
- 构建具备拖拽功能的看板/流程面板时
- 实现卡片在列之间的移动时
- 需要优先识别列而非卡片的精准碰撞检测时
- 想要实现Linear/Notion风格的拖拽用户体验,包含跟随光标覆盖层时
Tech Stack
技术栈
| Package | Version | Purpose |
|---|---|---|
| ^6.x | Core DnD context and hooks |
| ^9.x | Cursor snapping modifiers |
| ^3.x | CSS transform utilities |
| 包名 | 版本 | 用途 |
|---|---|---|
| ^6.x | 核心拖拽上下文与钩子 |
| ^9.x | 光标吸附修饰器 |
| ^3.x | CSS变换工具类 |
Installation
安装步骤
bash
bun add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/utilitiesbash
bun add @dnd-kit/core @dnd-kit/modifiers @dnd-kit/utilitiesArchitecture 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) │
└─────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────┐
│ 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)
1. 自定义碰撞检测(最重要)
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:
typescript
// 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;
};问题: 默认的碰撞检测在卡片上释放时,可能返回卡片ID而非列ID。
解决方案: 实现优先识别列容器的自定义碰撞检测:
typescript
// 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
2. 配置带数据类型的列
Columns must include for collision detection filtering:
data.typetypescript
const { setNodeRef } = useDroppable({
id: column.id,
data: {
type: "column", // CRITICAL: Enables collision filtering
columnId: column.id,
},
});列必须包含字段,以便碰撞检测过滤:
data.typetypescript
const { setNodeRef } = useDroppable({
id: column.id,
data: {
type: "column", // 关键:启用碰撞过滤
columnId: column.id,
},
});3. Card Setup with useDraggable (NOT useSortable)
3. 使用useDraggable配置卡片(而非useSortable)
For cross-column moves without within-column reordering, use :
useDraggabletypescript
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"
>如果仅需要跨列移动,不需要列内重新排序,请使用:
useDraggabletypescript
const {
attributes,
listeners,
setNodeRef,
isDragging,
} = useDraggable({
id: card.id,
data: {
type: "card",
card,
},
});
// 应用到整个卡片,实现任意位置拖拽
<Card
ref={setNodeRef}
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing touch-none select-none"
>4. Resolving Drop Target to Column ID
4. 将释放目标解析为列ID
Handle both column drops and card drops:
typescript
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]
);处理释放到列或卡片上的两种情况:
typescript
const findTargetColumnId = useCallback(
(overId: UniqueIdentifier | undefined): string | null => {
if (!overId) return null;
const overIdStr = String(overId);
// 检查是否直接是列ID
if (columnIds.has(overIdStr)) {
return overIdStr;
}
// 否则是卡片ID - 查找该卡片所属的列
const columnId = cardColumnMap.get(overIdStr);
return columnId || null;
},
[columnIds, cardColumnMap]
);5. DragOverlay with Cursor Snapping
5. 带光标吸附的DragOverlay
Problem: Default DragOverlay appears offset from cursor.
Solution: Use modifier and disable drop animation:
snapCenterToCursortypescript
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>问题: 默认的DragOverlay会与光标位置偏移。
解决方案: 使用修饰器并禁用释放动画:
snapCenterToCursortypescript
import { snapCenterToCursor } from "@dnd-kit/modifiers";
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={null} // 防止释放时的飞出效果
>
{activeCard ? (
<div className="w-[272px] pointer-events-none">
<Card card={activeCard} isDragging />
</div>
) : null}
</DragOverlay>6. Sensor Configuration
6. 传感器配置
typescript
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 5px movement before drag activates
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);typescript
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 移动5px后触发拖拽
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);7. Visual Feedback States
7. 视觉反馈状态
typescript
// 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}
/>typescript
// 面板跟踪激活状态
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeOverColumn, setActiveOverColumn] = useState<string | null>(null);
// 传递给列组件以提供视觉反馈
<Column
isOver={activeOverColumn === column.id}
isDragging={!!activeCard}
/>Complete Event Handlers
完整事件处理函数
typescript
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);
}, []);typescript
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;
// 通过mutation实现乐观更新
updateCard.mutate({
cardId,
data: { columnId: targetColumnId },
});
}, [cards, findTargetColumnId, updateCard]);
const handleDragCancel = useCallback(() => {
setActiveCard(null);
setActiveOverColumn(null);
}, []);Styling Patterns
样式实现模式
Card States
卡片状态
typescript
// 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"typescript
// 正在被拖拽的原卡片
isCurrentlyDragging && "opacity-30"
// DragOverlay中的卡片
isDragging && "shadow-xl ring-2 ring-primary/20 rotate-[1deg] cursor-grabbing"
// 整个卡片可拖拽
"cursor-grab active:cursor-grabbing touch-none select-none"Column States
列状态
typescript
// Column during any drag
isDragging && "bg-muted/30"
// Column being hovered
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"typescript
// 任意拖拽操作中的列
isDragging && "bg-muted/30"
// 被悬停的列
isOver && "bg-primary/5 ring-2 ring-primary/20 ring-inset"Empty Column State
空列状态
typescript
<div className={cn(
"border-2 border-dashed rounded-lg",
isOver ? "border-primary/40 bg-primary/5" : "border-border/50"
)}>
<p>{isDragging ? "Drop here" : "Drag items here"}</p>
</div>typescript
<div className={cn(
"border-2 border-dashed rounded-lg",
isOver ? "border-primary/40 bg-primary/5" : "border-border/50"
)}>
<p>{isDragging ? "Drop here" : "Drag items here"}</p>
</div>Common Mistakes to Avoid
需要避免的常见错误
❌ Using useSortable for cross-column moves
❌ 使用useSortable实现跨列移动
typescript
// WRONG - useSortable is for within-list reordering
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });typescript
// 错误 - useSortable适用于列表内重新排序
import { useSortable } from "@dnd-kit/sortable";
const { ... } = useSortable({ id: card.id });✅ Use useDraggable for cross-column moves
✅ 使用useDraggable实现跨列移动
typescript
// CORRECT - useDraggable for simple drag to droppable
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });typescript
// 正确 - useDraggable适用于拖拽到容器的场景
import { useDraggable } from "@dnd-kit/core";
const { ... } = useDraggable({ id: card.id });❌ Assuming over.id is always a column ID
❌ 假设over.id始终是列ID
typescript
// 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!
};typescript
// 错误 - over.id可能是卡片ID,如果释放到卡片上
const handleDragEnd = (event) => {
const newColumnId = over.id as string; // 可能是卡片ID!
};✅ Resolve over.id to column ID
✅ 将over.id解析为列ID
typescript
// CORRECT - Handle both column and card drop targets
const targetColumnId = findTargetColumnId(over.id);typescript
// 正确 - 处理释放到列或卡片的两种情况
const targetColumnId = findTargetColumnId(over.id);❌ Using drop animation with snapCenterToCursor
❌ 结合snapCenterToCursor使用释放动画
typescript
// WRONG - Causes fly-out effect on drop
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={{ duration: 200, easing: "ease-out" }}
>typescript
// 错误 - 会导致释放时的飞出效果
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={{ duration: 200, easing: "ease-out" }}
>✅ Disable drop animation
✅ 禁用释放动画
typescript
// CORRECT - Instant disappear on drop
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={null}
>typescript
// 正确 - 释放时立即消失
<DragOverlay
modifiers={[snapCenterToCursor]}
dropAnimation={null}
>❌ Separate drag handle
❌ 使用单独的拖拽手柄
typescript
// WRONG - Creates offset issues, worse UX
<Card>
<div {...listeners}>
<GripIcon /> {/* Only this area is draggable */}
</div>
<Content />
</Card>typescript
// 错误 - 会导致位置偏移,用户体验更差
<Card>
<div {...listeners}>
<GripIcon /> {/* 仅该区域可拖拽 */}
</div>
<Content />
</Card>✅ Entire card draggable
✅ 整个卡片可拖拽
typescript
// CORRECT - Linear-style drag anywhere
<Card {...listeners} {...attributes} className="cursor-grab">
<Content />
</Card>typescript
// 正确 - Linear风格的任意位置拖拽
<Card {...listeners} {...attributes} className="cursor-grab">
<Content />
</Card>Optimistic Updates with React Query
使用React Query实现乐观更新
typescript
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 });
},
});
}typescript
export function useUpdateCard(teamId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ cardId, data }) => updateCard(teamId, cardId, data),
// 乐观更新
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 };
},
// 错误时回滚
onError: (_err, _variables, context) => {
context?.previousCards?.forEach(([queryKey, data]) => {
queryClient.setQueryData(queryKey, data);
});
},
// 完成后重新获取数据
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 updatessrc/
├── components/
│ └── kanban/
│ ├── kanban-board.tsx # DndContext包装组件
│ ├── kanban-column.tsx # 可释放的列组件
│ ├── kanban-card.tsx # 可拖拽的卡片组件
│ └── kanban-skeleton.tsx # 加载状态组件
├── lib/
│ └── dnd/
│ └── collision-detection.ts # 自定义碰撞检测
└── hooks/
└── use-cards.ts # 带乐观更新的React Query钩子Related Asset Files
相关资源文件
| Asset | Description |
|---|---|
| Column-priority collision detection |
| Complete board implementation |
| Droppable column with visual feedback |
| Draggable card component |
| 资源 | 描述 |
|---|---|
| 优先识别列的碰撞检测实现 |
| 完整的看板实现 |
| 带视觉反馈的可释放列组件 |
| 可拖拽卡片组件 |
Checklist
检查清单
- Installed ,
@dnd-kit/core,@dnd-kit/modifiers@dnd-kit/utilities - Custom collision detection prioritizes columns
- Columns use with
useDroppabledata.type: "column" - Cards use (NOT
useDraggable)useSortable - resolves both column and card drop targets
findTargetColumnId - DragOverlay uses modifier
snapCenterToCursor - DragOverlay has
dropAnimation={null} - DragOverlay wrapper has fixed width matching card width
- Cards have classes
touch-none select-none cursor-grab - Visual feedback for and
isOverstatesisDragging - Optimistic updates with rollback on error
- 已安装,
@dnd-kit/core,@dnd-kit/modifiers@dnd-kit/utilities - 自定义碰撞检测优先识别列
- 列使用并设置
useDroppabledata.type: "column" - 卡片使用(而非
useDraggable)useSortable - 可解析列和卡片两种释放目标
findTargetColumnId - DragOverlay使用修饰器
snapCenterToCursor - DragOverlay设置
dropAnimation={null} - DragOverlay容器宽度与卡片宽度匹配
- 卡片添加类
touch-none select-none cursor-grab - 实现和
isOver状态的视觉反馈isDragging - 实现带错误回滚的乐观更新