1. Installation
yarn add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
2. Skeleton with DndContext and SortableContext
DndContext and SortableContext2.1. Basic Strcuture
2.1.1. Variant 1: One Sortable Region
2.1.1.1. Orchestration of components
- In this form we orchestrate the components as follows:
1<DndContext {...props}> 2 <SortableContext items={[ 3 "meaningful_prefix_item1Id", 4 "meaningful_prefix_item2Id" 5 ]}> 6 <SortableItem item={item1} /> 7 <SortableItem item={item2} /> 8 </SortableContext> 9</DndContext>
2.1.1.2. Standard props for DndContext
DndContextUsually props is of the form
{ sensors, collisionDetection, onDragStart, onDragEnd }
We defer the introduction of sensors to 〈2.2.1. sensors〉
2.1.1.3. Data for dragging logic
Refer to line-3 to 4 of 〈2.1.1.1. Orchestration of components〉, the ids provided in items props will be used to calculate the dragging logic/animation, those ids will be connected to the dragging component as follows:
const SortableItem () => { const { ..., setNodeRef } = useSortable({ id: "meaningful_prefix_item1Id" data: { type: "script", script: script, }, }) return ( <div ref={setNodeRef}> ... </div> ) }
where we can provide metadata for the dragging/dropping collision item in data props.
By experience the id provided in useSortable hook will be much more accessible than data when we handle our custom collisionDetection logic. Make sure to prepend a meaningful prefix whenever possible.
2.1.1.4. What is collisionDection any way?
collisionDection any way?Detailed definition will be dicussed in 〈2.2.2. collisionDetection〉.
By collisionDetection we mean the logic to determine which item to interact with when dragging another item.
For example, when we drag an item to a region, which one should be considered as a collision? The folder? Or the item nested inside of the folder? Do we have priority?
The collisionDetection logic will directly affect the outcome (the over part) in
const { active, over } = useDndContext();
or in onDragEnd callback:
// to be passed into DndContext const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; ... }
Now we can access our data by {active, over}.data.current.
2.1.1.5. Recap before we dive deeper
In general, two tasks are needed to be defined on our own:
-
collisionDetection: This relies heavily on ourid's initemsprop, so provide meaningful prefix for theidinuseSortablehook. -
onDragEnd: WhatAPIto call and what state to change, so providedatainuseSortablehook.
2.1.2. Variant 2: Multiple Sortable Regions
2.1.2.1. Orchestation
Standard usecase is like we have a list of items and we want to drag one item into a folder of another list of folders.
The orchestration will be like
1<DndContext 2 sensors={sensors} 3 collisionDetection={customCollisionDetection} 4 onDragStart={handleDragStart} 5 onDragEnd={handleDragEnd} 6> 7 {/* this is wrapped by a SortableContext */} 8 <SortableSubfolders 9 folders={folders} 10 /> 11 {/* this is wrapped by another SortableContext */} 12 <SortableScripts 13 items={items} 14 selectedFolderId={selectedFolderId} 15 /> 16 {/* DragOverlay for smooth animations */} 17 <DragOverlay> 18 {activeId && activeType === "script" && ( 19 <ScriptItem script={script} folderId={folderId} /> 20 )} 21 {activeId && activeType === "folder" && ( 22 <CollapsableFolder folder={folder} /> 23 )} 24 </DragOverlay> 25</DndContext>
2.1.2.2. Dragging logic and animation across two regions
Again the dragging collision logic and the drag-end logic need to be provided in DndContext.
Now to ensure smooth dragging animation, we need to disable the default animation in all SortableContexts
We then define what we to be dragged visually using DragOveraly component (line-17 to 24 in 〈2.1.2.1. Orchestation〉). Our collisionDetection logic now is also a key to the real-time sorting logic.
2.2. DndContext
DndContextThe root component that manages the drag-and-drop state.
<DndContext sensors={sensors} collisionDetection={arg=>customCollisionDetection(arg)} onDragStart={handleDragStart} onDragEnd={handleDragEnd} > {/* Draggable content here */} </DndContext>
We will be explaining the following key components in 〈2.2.1. sensors〉, 〈2.2.2. collisionDetection〉, 〈2.2.3. onDragStart〉 and 〈2.2.4. onDragEnd〉 respectively:
-
sensors: Input methods (mouse, touch, keyboard) -
collisionDetection: Algorithm to detect overlaps -
onDragStart: Callback when drag begins -
onDragEnd: Callback when item is dropped
2.2.1. sensors
sensorsimport { KeyboardSensor, PointerSensor } from "@dnd-kit/core"; ... const sensors = useSensors( useSensor(PointerSensor), // Mouse, touch, stylus useSensor(KeyboardSensor, { // Keyboard navigation coordinateGetter: sortableKeyboardCoordinates, }) );
2.2.1.1. PointerSensor
- Handles mouse, touch, and pen inputs
- Starts drag when pointer is pressed and moved
- Default activation: immediate on pointer down + move
2.2.1.2. KeyboardSensor
- This enables accessibility for keyboard users
- Allows drag/drop with arrow keys and Space/Enter
sortableKeyboardCoordinatesprovides standard keyboard sorting behavior
2.2.2. collisionDetection
collisionDetectionThis determines which droppable zone the dragged item is over. collisionDetection: CollisionDetection takes the following form:
type CollisionDetection = (args: { active: Active; collisionRect: ClientRect; droppableRects: RectMap; droppableContainers: DroppableContainer[]; pointerCoordinates: Coordinates | null; }) => Collision[];
where
export interface Collision { id: UniqueIdentifier; data?: Data; }
2.2.2.1. pointerWithin Strategy
pointerWithin StrategyIt is used to check if pointer is inside droppable.
The return
const pointerCollisions = pointerWithin(args)
are droppables that contain the pointer position.
2.2.2.2. rectIntersection Strategy
rectIntersection StrategyChecks if pointer is inside droppable.
Turn return
const rectCollisions = rectIntersection(args)
are droppables that intersect with dragged element's bounds
2.2.2.3. closestCenter Strategy
closestCenter StrategyThe return
const closestCollision = rectIntersection(args)
is the droppable with the center closest to the center of the dragged element.
2.2.2.4. Example of customCollisionDetection
customCollisionDetectionThis is an example used in my recent Tauri project. We defer our custom collision detection logic to section 〈3.3.1. Main DndContext Setup〉
2.2.3. onDragStart
onDragStartconst [activeId, setActiveId] = useState<number | null>(null); const [activeType, setActiveType] = useState<"script" | "folder" | null>(null);
const handleDragStart = (event: DragStartEvent) => { const { active } = event; // custom state for data display setActiveId(active.id as number); // custom state for data display setActiveType(active.data.current?.type || null); // Only set reordering state when dragging a folder, not a script if (active.data.current?.type === "folder") { dispatch(folderSlice.actions.setIsReorderingFolder(true)); } };
2.2.4. onDragEnd
onDragEndconst handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (!over || !folderResponse || !selectedRootFolderId) { dispatch(folderSlice.actions.setIsReorderingFolder(false)); return; } const activeData = active.data.current; const overData = over.data.current; // Case 1: Script dropped on folder (or root folder area) - move script to folder if (activeData?.type === "script" && overData?.type === "folder") { const script = activeData.script; const targetFolderId = overData.folderId; console.log("Moving script to folder:", script.id, "->", targetFolderId); moveScriptIntoFolder({ scriptId: script.id, folderId: targetFolderId, rootFolderId: selectedRootFolderId, }) .unwrap() .catch((error) => { console.error("Failed to move script:", error); }); } // Case 2: Reordering scripts else if (activeData?.type === "script" && overData?.type === "script") { const activeScript = activeData.script as ShellScriptResponse; const overScript = overData.script as ShellScriptResponse; // Find which folders contain the scripts const activeFolder = findFolderContainingScript(folderResponse, activeScript.id!); const overFolder = findFolderContainingScript(folderResponse, overScript.id!); if (!activeFolder || !overFolder) { console.error("Could not find folders containing scripts"); return; } // Case 2a: Scripts in the same folder - just reorder if (activeFolder.id === overFolder.id) { const oldIndex = activeFolder.shellScripts.findIndex( (s) => s.id === activeScript.id ); const newIndex = activeFolder.shellScripts.findIndex((s) => s.id === overScript.id); if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { console.log( `Reordering scripts in folder ${activeFolder.id}: ${oldIndex} -> ${newIndex}` ); reorderScripts({ folderId: activeFolder.id, fromIndex: oldIndex, toIndex: newIndex, rootFolderId: selectedRootFolderId, }) .unwrap() .catch((error) => { console.error("Failed to reorder scripts:", error); }); } } // Case 2b: Scripts in different folders - move and reorder else { const targetIndex = overFolder.shellScripts.findIndex( (s) => s.id === overScript.id ); if (targetIndex !== -1) { console.log( `Moving script ${activeScript.id} from folder ${activeFolder.id} to folder ${overFolder.id} at index ${targetIndex}` ); const currentScriptCount = overFolder.shellScripts.length; // Step 1: Move the script to the target folder moveScriptIntoFolder({ scriptId: activeScript.id!, folderId: overFolder.id, rootFolderId: selectedRootFolderId, }) .unwrap() .then(() => { // Step 2: Reorder the script to the target index // After moving, the script is at the end (currentScriptCount position) const fromIndex = currentScriptCount; if (fromIndex !== targetIndex) { return reorderScripts({ folderId: overFolder.id, fromIndex: fromIndex, toIndex: targetIndex, rootFolderId: selectedRootFolderId, }).unwrap(); } }) .catch((error) => { console.error("Failed to move and reorder script:", error); }); } } } // Case 3: Reordering folders else if (activeData?.type === "folder" && overData?.type === "folder") { const oldIndex = folderResponse.subfolders.findIndex((f) => f.id === active.id); const newIndex = folderResponse.subfolders.findIndex((f) => f.id === over.id); if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { console.log("Reordering folders"); reorderSubfolders({ parentFolderId: selectedRootFolderId, fromIndex: oldIndex, toIndex: newIndex, rootFolderId: selectedRootFolderId, }) .unwrap() .catch((error) => { console.error("Failed to reorder folders:", error); }); } } dispatch(folderSlice.actions.setIsReorderingFolder(false)); setActiveId(null); setActiveType(null); };
2.3. SortableContext
SortableContextThis enables items within a list to be reordered.
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; <DndContext> ... <SortableContext items={items.map(item => item.id)} strategy={verticalListSortingStrategy} > {items.map(item => ( <SortableItem key={item.id} item={item} /> ))} </SortableContext> </DndContext>
items: Array of item IDsstrategy: Sorting behavior (vertical, horizontal, grid, etc)
Since in my application I mostly focus on vertical list, we directly use the default verticalListSortingStrategy.
Remark. The purpose of items:
- ✅ Tell
dnd-kitwhich items belong to this sortable group - ✅ Track the order of items (for sorting calculations)
- ✅ Identify which items can be reordered together
- ❌ NOT used for rendering
- ❌ NOT used to access item data
The sortable item will be identified with the value in items via the useSortable or useDroppable hook when we provide { id }.
2.4. Hooks to Register Dragging and Dropping MetaData
2.4.1. useSortable
useSortableThis makes an individual item sortable, i.e., can be dragged and can be dropped (on mouse up).
const { attributes, // Props for the draggable element listeners, // Event handlers for dragging setNodeRef, // Ref to attach to the DOM element transform, // Current position transformation transition, // CSS transition isDragging, // Boolean indicating drag state } = useSortable({ id: item.id, data: { type: "item", item }, // Custom metadata });
Component (with ref={setNodeRef}) will register a value { id, data } which will be used to customize the collision logic and onDragEnd logic.
2.4.2. useDroppable
useDroppableThis creates a drop zone that can receive dragged items (cannot be dragged itself).
const { setNodeRef, // Ref for the droppable area isOver, // Boolean: true when item hovers over this zone } = useDroppable({ id: "drop-zone-1", data: { type: "folder", folderId: 123 }, });
Again any component having ref={setNodeRef} will become a droppable area.
2.5. DragOverlay
This renders a clone that follows the cursor while dragging (creates smooth animations).
<DragOverlay> {activeItem ? ( // activeItem is a local state <div className="opacity-80"> <ItemPreview item={activeItem} /> </div> ) : null} </DragOverlay>
3. Example
3.1. Repository
3.2. Brief Description
In the following example, we have
- Collapsable Folders
- Scripts
For which:
- Scripts can be sorted among themselves
- A script can be dragged into a folder
- Scripts within a folder can be sorted
ScriptsColumn (DndContext) ├── SortableSubfolders │ └── CollapsableFolder (useSortable + useDroppable) │ - Can be dragged (for reordering) │ - Can accept scripts (as drop target) │ └── SortableScripts └── SortableScriptItem (useSortable) - Can be dragged into folders or reordered
3.3. Implementation
3.3.1. Main DndContext Setup
File: src/app-component/ScriptsColumn/ScriptsColumn.tsx
1export default function ScriptsColumn() { 2 const [activeId, setActiveId] = useState<number | null>(null); 3 const [activeType, setActiveType] = useState<"script" | "folder" | null>(null); 4 5 const sensors = useSensors( 6 useSensor(PointerSensor), 7 useSensor(KeyboardSensor, { 8 coordinateGetter: sortableKeyboardCoordinates, 9 }) 10 );
Next we define our custom collision logic. Recall that CollisionDetection always return Collision[], as discussed in 〈2.2.2. collisionDetection〉.
11 // Custom collision detection 12 const customCollisionDetection: CollisionDetection = (args) => { 13 const { active } = args; 14 const isDraggingScript = active?.data.current?.type === "script"; 15 16 // When dragging scripts, prioritize folder drop zones 17 if (isDraggingScript) { 18 const pointerCollisions = pointerWithin(args); 19 if (pointerCollisions.length > 0) { 20 const droppableCollision = pointerCollisions.find( 21 ({ id }) => String(id).startsWith("folder-droppable-") 22 ); 23 if (droppableCollision) { 24 return [droppableCollision]; 25 } 26 } 27 } 28 29 // For folders, use normal rect intersection 30 return rectIntersection(args); 31 };
This collisionDetection will indirectly affect over in line-44 below.
For example, when a sortable lies inside a droppable, our custom collision logic tell us to return the one of highest priority (the folder-droppable) and affect what is returned in over.
32 const handleDragStart = (event: DragStartEvent) => { 33 const { active } = event; 34 setActiveId(active.id as number); 35 setActiveType(active.data.current?.type || null); 36 37 // Only set reordering state when dragging folders 38 if (active.data.current?.type === "folder") { 39 dispatch(folderSlice.actions.setIsReorderingFolder(true)); 40 } 41 }; 42 43 const handleDragEnd = (event: DragEndEvent) => { 44 const { active, over } = event; 45 46 if (!over || !folderResponse || !selectedFolderId) { 47 dispatch(folderSlice.actions.setIsReorderingFolder(false)); 48 return; 49 } 50 51 const activeData = active.data.current; 52 const overData = over.data.current; 53 54 // Case 1: Script dropped on folder 55 if (activeData?.type === "script" && overData?.type === "folder") { 56 moveScript({ 57 ...activeData.script, 58 folderId: overData.folderId, 59 }); 60 } 61 // Case 2: Reordering scripts 62 else if (activeData?.type === "script" && overData?.type === "script") { 63 // Reorder logic... 64 } 65 // Case 3: Reordering folders 66 else if (activeData?.type === "folder" && overData?.type === "folder") { 67 // Reorder logic... 68 } 69 70 dispatch(folderSlice.actions.setIsReorderingFolder(false)); 71 setActiveId(null); 72 setActiveType(null); 73 }; 74 75 return ( 76 <DndContext 77 sensors={sensors} 78 collisionDetection={customCollisionDetection} 79 onDragStart={handleDragStart} 80 onDragEnd={handleDragEnd} 81 > 82 <SortableSubfolders folderResponse={folderResponse} /> 83 <SortableScripts 84 folderResponse={folderResponse} 85 selectedFolderId={selectedFolderId} 86 /> 87 88 {/* DragOverlay for smooth animations */} 89 <DragOverlay> 90 {activeId && activeType === "script" && ( 91 <div className="opacity-80"> 92 <ScriptItem script={script} folderId={folderId} /> 93 </div> 94 )} 95 {activeId && activeType === "folder" && ( 96 <div className="opacity-80"> 97 <CollapsableFolder folder={folder} /> 98 </div> 99 )} 100 </DragOverlay> 101 </DndContext> 102 ); 103}
3.3.2. Sortable Folder (Dual Purpose: Sortable + Droppable)
File: src/app-component/ScriptsColumn/CollapsableFolder.tsx
As displayed in the video of 〈3.2. Brief Description〉, folder can be dragged and dropped, it does not come as a surprise that we need to use both useSortable and useDroppable hooks:
1export default function CollapsableFolder({ folder }) { 2 // Make folder sortable (for reordering folders) 3 const { 4 attributes, 5 listeners, 6 setNodeRef: setSortableNodeRef, 7 transform, 8 transition, 9 isDragging, 10 setActivatorNodeRef, 11 } = useSortable({ 12 id: folder.id, 13 data: { 14 type: "folder", 15 folderId: folder.id, 16 folder: folder, 17 }, 18 }); 19 20 // Make folder droppable (to accept scripts) 21 const { setNodeRef: setDroppableNodeRef, isOver } = useDroppable({ 22 id: `folder-droppable-${folder.id}`, 23 data: { 24 type: "folder", 25 folderId: folder.id, 26 folder: folder, 27 }, 28 }); 29 30 // Get active drag context 31 const { active } = useDndContext(); 32 const isDraggingScript = active?.data.current?.type === "script"; 33 34 // Only highlight when a script hovers over this folder 35 const showHighlight = isOver && isDraggingScript;
In line-35 we also require the folder be highlighted only when we are dragging a script.
Finally we set our folder as both droppable and sortable via ref attribute:
36 // Combine both refs 37 const setNodeRef = (node: HTMLElement | null) => { 38 setSortableNodeRef(node); 39 setDroppableNodeRef(node); 40 }; 41 42 const style: React.CSSProperties = { 43 transform: CSS.Transform.toString(transform), 44 transition: isDragging ? "none" : transition, 45 opacity: isDragging ? 0 : 1, // Hide original when dragging 46 }; 47 48 return ( 49 <div ref={setNodeRef} style={style} {...attributes}> 50 <div className={clsx({ 51 "bg-gray-400 dark:bg-neutral-600": showHighlight, 52 // Other styles... 53 })}> 54 <div ref={setActivatorNodeRef} {...listeners}> 55 <GripVertical /> 56 </div>
Note that line-54 to 56 activated the draggability of an item. Only this "handle" can activate dragging:

Finally:
57 <Folder className="w-4 h-4" fill="currentColor" /> 58 {folder.name} 59 </div> 60 </div> 61 ); 62}
3.3.3. Sortable Script
File: src/app-component/ScriptsColumn/SortableScriptItem.tsx
This is relatively easy as it is the most basic draggable unit:
export default function SortableScriptItem({ script, folderId }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, setActivatorNodeRef, } = useSortable({ id: script.id, data: { type: "script", script: script, }, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition: isDragging ? "none" : transition, opacity: isDragging ? 0 : 1, // Hide when dragging }; return ( <div ref={setNodeRef} style={style} {...attributes}> <div ref={setActivatorNodeRef} {...listeners}> <GripVertical className="w-4 h-4" /> </div> <ScriptItem script={script} folderId={folderId} /> </div> ); }
3.4. Key Challenges & Solutions
3.4.1. Challenge 1: Crossing SortableContext Boundaries
Problem: Dragging a script from the scripts list to a folder in the folders list caused animation interruption.
Solution:
- Use a single
DndContextwrapping both lists - Make folders both sortable AND droppable
- Add
DragOverlayto show a smooth clone following the cursor
// Single DndContext for both lists <DndContext> <SortableContext items={folderIds}> {/* Folders */} </SortableContext> <SortableContext items={scriptIds}> {/* Scripts */} </SortableContext> <DragOverlay> {/* Smooth clone */} </DragOverlay> </DndContext>
Note that we have disabled (hided) the original clone of sortable object by:
In some sense we have "moved" this clone into the DragOverlay. This makes it possible to have a smooth transition from one SortableContext to another SortableContext.
3.4.2. Challenge 2: Identifying Drop Targets
Problem: How to distinguish between dropping on a folder vs reordering?
Solution: We have prioritized the collision logic while dragging a script:
// If we have script collisions, always prioritize them for sorting if (scriptCollisions.length > 0) { // ... returns script collisions return [...scriptCollisions, ...droppablesToInclude]; }
So when we drag a script:
- Over another script → Collision detection returns the script ID
over.id = 456(another script)over.data.current.type = "script"- Result: Reordering happens
- Over empty folder area → Collision detection returns the droppable ID
over.id = "folder-droppable-20"over.data.current.type = "folder"- Result: Moving to folder happens
4. References
-
dndkit, dnd-kit Documentation
-
dndkit, dnd-kit Examples
-
dndkit, GitHub Repository
















