关于 web-dnd-dnd-kit
web-dnd-dnd-kit 是为使用 @dnd-kit 构建拖放交互的开发者提供的编码技能,涵盖基础模式与进阶场景。它说明传感器、碰撞检测、覆盖层及无障碍支持的配置要点。
主要功能
- 01可排序列表与拖放区模式
- 02传感器和键盘操作配置
- 03可组合的碰撞检测算法
- 04跨容器拖放与 DragOverlay
- 05屏幕阅读器播报与拖动手柄
适用场景
- 01实现待办事项或播放列表排序
- 02构建可移动卡片的 Kanban 看板
- 03创建垃圾桶或分类投放区
- 04支持键盘操作的拖放界面
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-dnd-dnd-kit安装后在 Claude Code 中输入/web-dnd-dnd-kit确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/advanced.mdcore.md详细介绍
@dnd-kit 拖放模式
快速指南:
@dnd-kit/core提供基础组件——DndContext、useDraggable、useDroppable——而@dnd-kit/sortable则添加了useSortable、SortableContext和arrayMove,用于创建可重新排序的列表。拖动期间,DOM 中的元素不会重新排序:元素通过 CSS transform 移动,状态则在放下时更新。输入通过传感器传入;传感器是独立的插件,因此键盘支持需要通过添加传感器实现,并非默认就有的行为。碰撞检测可通过插件更换,具体选择取决于布局。每当被拖动的元素可能在拖动过程中被裁剪或卸载时,都需要使用DragOverlay。
详细资源:
- examples/core.md — 可拖动和可放置组件、可排序列表、传感器设置、碰撞检测组合、播报、拖动手柄
- examples/advanced.md — 与可排序列表搭配使用的 DragOverlay、多容器看板、修饰器、自定义碰撞检测、禁用的条目、条目元数据
- reference.md — 钩子签名和返回值、事件处理程序类型、排序策略、碰撞算法、修饰器、默认按键绑定、应用的 ARIA 属性
适用方案
- **单个列表,条目留在原处。**使用单个
SortableContext、closestCenter,并在条目本身应用 transform。这是最简单的方案,无需额外保持任何内容挂载。请参阅 examples/core.md。 - **条目跨容器移动,或列表可滚动。**这两种情况都会导致被拖动的元素在拖动过程中卸载或被裁剪,因此需要使用
DragOverlay,并在状态中保存activeId。多容器场景还需要使用closestCorners和onDragOver处理程序来完成转移。请参阅 examples/advanced.md。 - 使用放置区而非重新排序——例如垃圾桶、上传目标、分类区。仅使用
@dnd-kit/core,无需安装 sortable 包,通常使用pointerWithin。请参阅 examples/core.md 中的模式 1 和 4。
<critical_requirements>
编写 @dnd-kit 代码前
将每个参与项都包在一个 <DndContext> 中。 这些 Hook 会从其上下文读取传感器、碰撞状态和当前拖动项;在上下文之外,它们会返回无效值,而不会抛出错误,因此缺少 Provider 时看起来就像什么都没发生。
添加 KeyboardSensor,对于可排序列表,还要使用 sortableKeyboardCoordinates。 传感器需要显式启用,因此仅配置了指针输入的上下文完全无法通过键盘操作。坐标获取器会让方向键在项目之间逐步移动,而不是按固定像素偏移移动。
为 PointerSensor 设置激活约束——可以是 distance,触屏场景下也可以是带有 tolerance 的 delay。如果没有约束,每次点击都会开始拖动,而同时也是链接或按钮的项目就无法点击。
保持 DragOverlay 挂载,并有条件地渲染其子元素。 卸载子元素时,Overlay 会执行放下动画;如果卸载的是 Overlay 本身,就会移除本应执行动画的元素。
在放下处理函数中返回一个新数组。 arrayMove 是纯函数,会返回重新排序后的副本——它不会修改状态,而原地修改现有数组会让 React 无法据此重新渲染。
</critical_requirements>
自动检测: @dnd-kit/core、@dnd-kit/sortable、@dnd-kit/utilities、@dnd-kit/modifiers、DndContext、useDraggable、useDroppable、useSortable、SortableContext、DragOverlay、useSensor、useSensors、PointerSensor、KeyboardSensor、TouchSensor、closestCenter、closestCorners、rectIntersection、pointerWithin、arrayMove、sortableKeyboardCoordinates、verticalListSortingStrategy、setActivatorNodeRef、CSS.Transform、restrictToVerticalAxis
适用场景:
- 可排序列表——可重新排序的待办事项、播放列表、导航项和表单字段
- 看板以及任何需要在容器之间移动项目的布局
- 放置区:垃圾桶、分类箱、页面内上传目标
- 限制只能从项目特定部分开始拖动的拖动手柄
- 可通过键盘操作且会向屏幕阅读器播报的拖动交互
- 将移动范围限制在某个轴、父元素或视口内
其他部分处理:
- 不通过拖动交互重新排序——上移/下移控件是普通按钮,使用相同的数组操作
- 从操作系统拖入的文件属于浏览器自身的拖放功能,不会经过传感器
- 以物理效果或手势驱动的移动——这类交互的重点是动画,而不是放置目标
- 被拖动和悬停项目的外观——此处的每个组件都接收
style和className,具体视觉风格由使用它的组件决定 - 持久化新顺序——放下处理函数会生成一个数组,至于写入何处不属于此技能的范畴
拖动期间,DOM 中的任何元素都不会移动。 项目通过 CSS transform 移位,数组只会在放下时重新排序。因此,SortableContext 的 items 必须与渲染子元素的 ID 及其顺序一致——策略会根据每个项目在该数组中的索引计算其位移,若两者不匹配,就会算出错误的偏移量。
输入是插件,不是行为。 没有传感器的 DndContext 不会响应任何操作。这意味着键盘支持需要主动添加,而非默认附带;这也是最常见的遗漏项。
无障碍功能已内置,但不会自动完善。 useDraggable 会自行设置 role、aria-roledescription、tabindex 和 aria-describedby;但它默认播报的内容只是项目 ID,对屏幕阅读器用户毫无帮助。需要通过提供能描述位置的 announcements 来补足。
<decision_framework>
选择哪些包
Reorderable lists -> @dnd-kit/core + @dnd-kit/sortable + @dnd-kit/utilities
Drop zones only -> @dnd-kit/core
Constrained movement -> add @dnd-kit/modifiers
Transform 还是 DragOverlay
Items move between containers? -> DragOverlay (source unmounts mid-drag)
Draggable inside a scrolling or
virtualized container? -> DragOverlay (transform is clipped by overflow)
Preview should differ from the item? -> DragOverlay
None of these -> transform on the item; simpler, less state
使用哪种碰撞算法
Single sortable list -> closestCenter forgiving, needs no overlap
Stacked containers (Kanban) -> closestCorners resolves nested targets
Precise zones (trash, bins) -> pointerWithin pointer must be inside
General drop targets -> rectIntersection the default
键盘拖动时没有指针可供 pointerWithin 检测,因此它不会返回任何结果——应将它与 closestCenter 组合用作回退算法,而不是单独使用。
排序策略、修饰符和完整算法表请参阅 reference.md。
</decision_framework>
核心模式
模式 1:基本拖放
DndContext 连接这两个 Hook。useDraggable 为元素提供监听器和 ARIA 属性;useDroppable 注册目标,并报告是否有元素位于其上方。
function Draggable({ id, children }: DraggableProps) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform) }}
{...listeners}
{...attributes}
>
{children}
</div>
);
}
<DndContext
onDragEnd={({ over }) => setDroppedIn(over ? String(over.id) : null)}
>
<Draggable id="item-1">Drag me</Draggable>
<Droppable id="zone-a">Drop zone</Droppable>
</DndContext>;
拖动结束时,如果指针不在任何目标上,over 就是 null,这种情况视为取消。
完整代码:examples/core.md
模式 2:可排序列表
useSortable 是 useDraggable 和 useDroppable 的组合,因此其 ID 在两者之间必须唯一。arrayMove 会在放下时生成重新排序后的数组。
function handleDragEnd({ active, over }: DragEndEvent) {
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex((i) => i.id === active.id);
const newIndex = prev.findIndex((i) => i.id === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={items.map((i) => i.id)} // same ids, same order as the children below
strategy={verticalListSortingStrategy}
>
{items.map((item) => (
<SortableItem key={item.id} id={item.id}>
{item.label}
</SortableItem>
))}
</SortableContext>
</DndContext>;
完整代码:examples/core.md
模式 3:传感器和激活约束
传感器决定何时开始拖动。使用 useSensors 组合传感器。
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: ACTIVATION_DISTANCE_PX }, // click stays a click
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, // arrow keys step item to item
}),
);
<DndContext sensors={sensors}>{/* ... */}</DndContext>;
如果鼠标和触摸需要不同的约束,请分别使用 MouseSensor 和 TouchSensor,而不是 PointerSensor——触摸通常需要设置 delay 和 tolerance,以免将滚动手势识别为拖动。
完整代码:examples/core.md
模式 4:碰撞检测
传入一种内置算法;如果不同目标需要不同的行为,也可以将它们组合成一个函数。
const composedCollision: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args);
if (pointerCollisions.length > 0) return pointerCollisions;
return closestCenter(args); // fallback: pointerWithin returns nothing for keyboard drags
};
<DndContext collisionDetection={composedCollision}>{/* ... */}</DndContext>;
在委托给算法之前筛选 args.droppableContainers,就能对垃圾区使用一种算法,对其周围的列表使用另一种算法。
完整代码:examples/core.md
模式 5:DragOverlay
覆盖层会在独立图层中渲染拖动预览,不受列表溢出区域的限制,也不依赖源项目是否仍然存在。
<DndContext
onDragStart={({ active }) => setActiveId(String(active.id))}
onDragEnd={(event) => {
handleDragEnd(event);
setActiveId(null);
}}
onDragCancel={() => setActiveId(null)} // Escape ends the drag too
>
{/* containers and sortable items */}
<DragOverlay>
{activeItem ? <ItemPreview item={activeItem} /> : null}
</DragOverlay>
</DndContext>
其子元素仅用于展示——在覆盖层内调用 useDraggable 会为正在拖动的项目重复注册一个可拖动元素。
完整代码:examples/advanced.md
模式 6:多容器可排序(看板)
每一列都是一个可放置区域,同时包裹自己的 SortableContext。项目跨越边界时,onDragOver 会执行转移;onDragEnd 会确定项目在列内的顺序。
function handleDragOver({ active, over }: DragOverEvent) {
if (!over) return;
const from = findContainer(active.id);
const to = findContainer(over.id);
if (!from || !to || from === to) return; // same column: onDragEnd handles it
setColumns((prev) => moveBetweenColumns(prev, active.id, over.id, from, to));
}
<DndContext
collisionDetection={closestCorners}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{Object.entries(columns).map(([id, items]) => (
<KanbanColumn key={id} id={id} items={items} />
))}
<DragOverlay>
{activeItem ? <KanbanCard item={activeItem} /> : null}
</DragOverlay>
</DndContext>;
由于碰撞检测解析出的 over.id 可能是列 ID,也可能是项目 ID,因此 findContainer 必须能处理这两种情况。
完整代码:examples/advanced.md
模式 7:键盘和屏幕阅读器播报
内置播报会通过 ID 指明项目。应将其替换为位置播报,这是屏幕阅读器用户跟踪移动所需的信息。
function createAnnouncements(items: string[]) {
const at = (id: UniqueIdentifier) =>
`position ${items.indexOf(String(id)) + 1} of ${items.length}`;
return {
onDragStart: ({ active }) => `Picked up item at ${at(active.id)}`,
onDragOver: ({ over }) =>
over ? `Moved to ${at(over.id)}` : "No longer over a drop target",
onDragEnd: ({ over }) =>
over ? `Dropped at ${at(over.id)}` : "Dropped outside a valid target",
onDragCancel: ({ active }) => `Cancelled. Returned to ${at(active.id)}`,
};
}
<DndContext announcements={createAnnouncements(itemIds)}>
{/* ... */}
</DndContext>;
screenReaderInstructions 则负责另一部分——聚焦可拖动元素时读出的说明,默认语言为英语。
完整代码:examples/core.md
模式 8:拖动手柄
setActivatorNodeRef 会标记用于启动拖动的元素,与实际移动的元素分开。
<div ref={setNodeRef} style={style}>
<button
ref={setActivatorNodeRef}
{...listeners}
{...attributes}
aria-label={`Reorder ${label}`}
>
☰
</button>
{children}
</div>
监听器和属性应放在手柄上,而不是容器上,这样项目的其余部分仍可点击和选择。
完整代码:examples/core.md
模式 9:修饰符
修饰符会在应用拖动位置前对其进行转换。DndContext 和 DragOverlay 分别接受各自独立的修饰符。
<DndContext modifiers={[restrictToParentElement]}>
<DragOverlay modifiers={[restrictToWindowEdges]}>
{activeItem ? <ItemPreview item={activeItem} /> : null}
</DragOverlay>
</DndContext>
在垂直列表上使用 restrictToVerticalAxis,可以消除导致重新排序手感不精准的横向偏移。
完整代码:examples/advanced.md
<red_flags>
风险信号
运行时会出错:
- 在
DragOverlay子项上使用useDraggable——会为已在拖动的项再次注册一个可拖动项 SortableContext的items与渲染的子项在 ID 和顺序上不匹配——位移根据该数组中的索引计算,因此动画和放置位置会出错useSortable与另一个可拖动项或可放置项共用 ID——它会同时注册为两者,因此其 ID 在两组中都必须唯一;而普通的useDraggable和useDroppable可以共用一个 ID,因为它们注册在不同的存储中- 单独使用
pointerWithin——键盘拖动没有指针,因此无法解析出目标,放置操作不会执行
容易令人意外的行为:
- 对堆叠的容器使用
closestCenter时,会解析到列,而不是列内的卡片;closestCorners会测量全部四个角,并解析到嵌套目标 rectSortingStrategy是默认策略,不支持虚拟化;垂直和水平列表策略支持虚拟化CSS.Transform.toString()包含scaleX/scaleY;CSS.Translate.toString()仅包含位置,通常是列表所需的形式CSS.Transform.toString(null)会返回undefined,可以安全地赋值给style.transformDragOverlay默认不会通过 Portal 渲染,因此仍受祖先元素的overflow和层叠上下文影响——将其包在createPortal中即可脱离这些限制active.data.current和over.data.current会携带传入的data,这样一个处理程序就能在不查找卡片或列的情况下区分两者- 屏幕阅读器说明和播报默认使用英语,替换它们时不会自动翻译
- 默认情况下,按 Space 或 Enter 键拾取并放置,按方向键移动,按 Escape 键取消——Escape 键会触发
onDragCancel,它必须重置与onDragEnd相同的状态