0%

Dnd-Kit

November 8, 2025

React

1. Installation

yarn add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

2. Skeleton with DndContext and SortableContext

2.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

Usually 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?

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 our id's in items prop, so provide meaningful prefix for the id in useSortable hook.

  • onDragEnd: What API to call and what state to change, so provide data in useSortable hook.

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

The 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
import { 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
  • sortableKeyboardCoordinates provides standard keyboard sorting behavior
2.2.2. collisionDetection

This 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

It 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

Checks 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

The 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

This 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
const [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
const 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

This 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 IDs
  • strategy: 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-kit which 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

This 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

This 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:

  1. Use a single DndContext wrapping both lists
  2. Make folders both sortable AND droppable
  3. Add DragOverlay to 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:

  1. Over another script → Collision detection returns the script ID
    • over.id = 456 (another script)
    • over.data.current.type = "script"
    • Result: Reordering happens
  2. 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