HomeDrag & Drop in React with Dnd Kit

Drag & Drop in React with Dnd Kit

February 21, 2023/4 min read

DnD Kit is a relatively new, hook-based library that makes it easy to create performant and accessible drag-and-drop UI interactions in React. I have been using the library a lot at Super, particularly in our new dashboard, and I wanted to share some examples of the kind of interactions that can be built with it.

In this post, I will provide a brief overview of the core concepts behind @dnd-kit and then i’ll move on to a number of code examples with explanations. If you need more detail about anything i cover, you can always check out the documentation here.

Core Concepts

Drag-and-drop interactions on the web typically involve three elements:

  • a draggable component that can be dragged,
  • a droppable area where draggable components can be dropped over, and
  • a system that manages the interaction between the draggable components and the droppable areas.

To see how these elements typically come together, you can play with the demo below:

OL

In the demo, we can drag our draggable components and drop them anywhere on the page. However, we can also restrict the dropping of our draggable components to particular areas:

Q1
I2
P3
G4

DndKit is designed around this approach, providing three core primitives exported from the @dnd-kit/core package:

  1. The useDraggable hook - A React hook which makes HTML elements draggable.
  2. The useDroppable hook - A React hook which makes HTML elements droppable areas.
  3. The DndContext - A context provider which manages the interaction between draggable and droppable components using the React Context API.

Drag-and-drop interactions often require more than just dragging and dropping components. For example, take a look at the demo below:

SORTABLE 1
SORTABLE 2
SORTABLE 3
SORTABLE 4

In the demo, we have a list. When we drag a single item, it automatically sorts with its other siblings. This type of interaction is known as a sortable interface, where components are both draggable and droppable. We can refer to these components as "Sortable Components".

Sortable components can be used in a variety of ways. For instance, we can have multiple containers with sortable list items, enabling users to sort the containers, list items, and drag items between them:

CONTAINER A
A1
A2
A3
CONTAINER B
B1
B2
B3

To handle this use case, @dnd-kit exposes a set of presets from the @dnd-kit/sortable package:

  1. The useSortable hook - A react hook which makes HTML elements sortable. The hook is a combination of the useDraggable and useDroppable hooks.
  2. The SortableContext - An additional context provider which manages the interactions between sortable components.

Let's now explore how to use the primitives and presets to create various drag-and-drop interactions.

Examples

We'll be looking at four common types of drag and drop interactions:

  1. Free Drag-and-Drop - Drag draggable components across the entire page, similar to apps such as Figma and Excalidraw.
  2. Droppable Containers - Drag multiple draggable components into one or more designated droppable containers.
  3. Single Container Sortable List - A sortable list within a single container.
  4. Multi-Container Sortable List - Sortable lists within multiple sortable containers.

Free Drag-and-Drop

A user interface that allows users to pick up and drag an element across the entire page.

Open on CodeSandbox
  import React from "react";
  import type { DragEndEvent } from "@dnd-kit/core";
  import {
    DndContext,
    KeyboardSensor,
    MouseSensor,
    TouchSensor,
    useSensor,
    useSensors
  } from "@dnd-kit/core";
  import "./styles.css";
  import { Draggable } from "./Draggable";
  
  const draggable = [
    {
      id: "DG",
      name: "D",
      position: {
        x: 24,
        y: 24
      }
    },
    {
      id: "KA",
      name: "K",
      position: {
        x: 164,
        y: 164
      }
    }
  ];
  
  function App() {
    //Sensors are an abstraction to detect different input methods in
    //order to initiate drag operations, respond to movement and end or
    //cancel the operation. See more here: https://docs.dndkit.com/api-documentation/sensors
    const sensors = useSensors(
      useSensor(KeyboardSensor),
      useSensor(TouchSensor),
      useSensor(MouseSensor)
    );
  
    const [draggables, setDraggables] = React.useState([...draggable]);
  
    return (
      <div style={{ width: "100%", minHeight: "100vh" }} className="dotted-bg">
        <DndContext onDragEnd={handleDragEnd} sensors={sensors}>
          {draggables.map((draggable) => (
            <Draggable
              {...draggable}
              key={draggable.id}
              //Pass current position as css styles, makes sure we can drag element
              //across the viewport
              styles={{
                position: "absolute",
                left: `${draggable.position.x}px`,
                top: `${draggable.position.y}px`
              }}
            />
          ))}
        </DndContext>
      </div>
    );
  
    function handleDragEnd(ev: DragEndEvent) {
      //Get the id of the active draggable
      const activeId = ev.active.id;
  
      //Update the state
      setDraggables((draggables) => {
        return draggables.map((draggable) => {
          //if draggable id matches the active id
          if (draggable.id === activeId) {
            return {
              ...draggable,
              //update its position with the new position in the delta object in
              //the drag end event
              position: {
                x: draggable.position.x += ev.delta.x,
                y: draggable.position.y += ev.delta.y
              }
            };
          }
          //return draggable that is not active
          return draggable;
        });
      });
    }
  }
  export default App;

Droppable Containers

A user interface that allows users to pick up and drag an element into designated droppable areas

Open on CodeSandbox
  import React from "react";
import { DragOverEvent, DragStartEvent } from "@dnd-kit/core";
import {
  DndContext,
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  useSensor,
  DragOverlay,
  useSensors
} from "@dnd-kit/core";
import "./styles.css";
import { Draggable } from "./Draggable";
import { Droppable } from "./Droppable";
import { items, Item } from "./items";

function App() {
  //Sensors are an abstraction to detect different input methods in
  //order to initiate drag operations, respond to movement and end or
  //cancel the operation. See more here: https://docs.dndkit.com/api-documentation/sensors
  const sensors = useSensors(
    useSensor(KeyboardSensor),
    useSensor(TouchSensor),
    useSensor(MouseSensor)
  );
  const [draggables, setDraggables] = React.useState([...items]);
  const [containers] = React.useState(["A", "B"]);
  //The state of the active draggable, used to render the drag overlay below
  const [activeItem, setActiveItem] = React.useState<Item | undefined>(
    undefined
  );

  return (
    <DndContext
      sensors={sensors}
      onDragOver={handleDragOver}
      onDragStart={handelDragStart}
    >
      <div className="app">
        <div className="containers">
          {containers.map((id) => (
            <Droppable key={id} id={id} className="droppable">
              {draggables
                //render draggables within thier respective containers
                .filter((draggable) => draggable.containerId === id)
                .map((draggable) => (
                  <Draggable key={draggable.id} {...draggable} />
                ))}
            </Droppable>
          ))}
        </div>
        <Droppable id="ROOT" className="root-droppable">
          {draggables
            //only render draggables with have a containerId of ROOT
            .filter((draggable) => draggable.containerId === "ROOT")
            .map((draggable) => (
              <Draggable key={draggable.id} {...draggable} />
            ))}
        </Droppable>
      </div>
      <DragOverlay>
        {activeItem && (
          //Render a drag overlay when using multiple containers
          // check here https://docs.dndkit.com/api-documentation/draggable/drag-overlay for more info
          <div className="draggable draggable-overlay">{activeItem.name}</div>
        )}
      </DragOverlay>
    </DndContext>
  );

  function handelDragStart(ev: DragStartEvent) {
    const { active } = ev;
    const activeId = active.id;
    const activeDraggable = draggables.find(
      (draggable) => draggable.id === activeId
    );
    setActiveItem(activeDraggable);
  }

  function handleDragOver(ev: DragOverEvent) {
    const { active, over } = ev;
    if (!over) return;
    //the id of the active draggable
    const activeId = active.id;

    //The id of the continaer a draggable is dragged over
    //in our example the overId can either be ROOT, A or B
    const overId = over.id as string;

    setDraggables((draggables) => {
      return draggables.map((draggable) => {
        //if we are dragging a draggable over a container
        if (draggable.id === activeId) {
          return {
            ...draggable,
            //update its containerId to match the overId
            containerId: overId
          };
        }
        return draggable;
      });
    });
  }
}
export default App;

Single Container Sortables

A container containing a list of items that can be rearranged by dragging them.

Open on CodeSandbox
import type { DragEndEvent } from "@dnd-kit/core";
  import {
    closestCenter,
    DndContext,
    KeyboardSensor,
    TouchSensor,
    MouseSensor,
    useSensor,
    useSensors
  } from "@dnd-kit/core";
  import {
    arrayMove,
    SortableContext,
    sortableKeyboardCoordinates,
    verticalListSortingStrategy
  } from "@dnd-kit/sortable";
  import React from "react";
  import { items } from "./items";
  import { SortableItem } from "./SortableItem";
  
  export default function App() {
    const [sortables, setSortables] = React.useState([...items]);
    //Sensors are an abstraction to detect different input methods in
    //order to initiate drag operations, respond to movement and end or
    //cancel the operation. See more here: https://docs.dndkit.com/api-documentation/sensors
    const sensors = useSensors(
      useSensor(TouchSensor),
      useSensor(MouseSensor),
      useSensor(KeyboardSensor, {
        coordinateGetter: sortableKeyboardCoordinates
      })
    );
  
    return (
      <DndContext
        //collision detection algorithm best suited for sortable interfaces
        //read more here: https://docs.dndkit.com/api-documentation/context-provider/collision-detection-algorithms
        collisionDetection={closestCenter}
        sensors={sensors}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          //read more on the SortableContext here https://docs.dndkit.com/presets/sortable/sortable-context
          items={sortables}
          strategy={verticalListSortingStrategy}
        >
          <div className="app">
            {sortables.map((sortable) => (
              <SortableItem key={sortable.id} {...sortable} />
            ))}
          </div>
        </SortableContext>
      </DndContext>
    );
  
    function handleDragEnd(event: DragEndEvent) {
      const { active, over } = event;
      const activeId = active.id;
      const overId = over?.id;
  
      if (activeId && overId && activeId !== overId) {
        setSortables((items) => {
          //update items to thier new indexes
          const oldIndex = sortables.findIndex((f) => f.id === activeId);
          const newIndex = sortables.findIndex((f) => f.id === overId);
          return arrayMove(items, oldIndex, newIndex);
        });
      }
    }
  }

Multi-Container Sortables

A list that can be sorted and items that can be dropped into multiple containers.

Open on CodeSandbox
  import {
    DndContext,
    KeyboardSensor,
    TouchSensor,
    useSensor,
    useSensors,
    MouseSensor,
    closestCorners,
    DragEndEvent,
    DragOverEvent,
    DragStartEvent,
    DragOverlay
  } from "@dnd-kit/core";
  import {
    arrayMove,
    horizontalListSortingStrategy,
    SortableContext,
    sortableKeyboardCoordinates
  } from "@dnd-kit/sortable";
  import React from "react";
  import { initialItems, Item } from "./items";
  import {
    OverlayContainer,
    SortableContainer,
    SortableContainerProps
  } from "./SortableContainer";
  import { OverlayItem } from "./SortableItem";
  
  export default function App() {
    const [sortables, setSortables] = React.useState([...initialItems]);
  
    //The state of the active draggable, used to render the drag overlay below
    const [activeItem, setActiveItem] = React.useState<
      SortableContainerProps | Item | null
    >(null);
    //Sensors are an abstraction to detect different input methods in
    //order to initiate drag operations, respond to movement and end or
    //cancel the operation. See more here: https://docs.dndkit.com/api-documentation/sensors
    const sensors = useSensors(
      useSensor(TouchSensor),
      useSensor(MouseSensor),
      useSensor(KeyboardSensor, {
        coordinateGetter: sortableKeyboardCoordinates
      })
    );
  
    const containerIds = sortables.map((s) => s.id);
  
    return (
      <DndContext
        //collision detection algorithm best suited for sortable interfaces and multiple containers
        //read more here: https://docs.dndkit.com/api-documentation/context-provider/collision-detection-algorithms
        collisionDetection={closestCorners}
        sensors={sensors}
        onDragEnd={handleDragEnd}
        onDragOver={handleDragOver}
        onDragStart={handleDragStart}
      >
        <SortableContext
          //read more on the SortableContext here https://docs.dndkit.com/presets/sortable/sortable-context
          items={sortables}
          strategy={horizontalListSortingStrategy}
        >
          <div className="app">
            {sortables.map((s) => (
              <SortableContainer
                key={s.id}
                id={s.id}
                name={s.name}
                items={s.items}
              />
            ))}
          </div>
        </SortableContext>
        <DragOverlay>
          {activeItem ? (
            //Render a drag overlay for either the container or sortable item based on the id of the active item
            // check here https://docs.dndkit.com/api-documentation/draggable/drag-overlay for more info
            <>
              {containerIds.includes(activeItem.id) ? (
                <OverlayContainer {...(activeItem as SortableContainerProps)} />
              ) : (
                <OverlayItem {...(activeItem as Item)} />
              )}
            </>
          ) : null}
        </DragOverlay>
      </DndContext>
    );
  
    //Returns the id of a container based on the id of any of its child items
    function findContainer(id?: string) {
      if (id) {
        if (containerIds.includes(id)) return id;
        const container = sortables?.find((i) =>
          i.items?.find((l) => l?.id === id)
        );
  
        return container?.id;
      }
    }
  
    /*Returns true if we are sorting containers
     * we will know if we are sorting containers if the id of the active item is a
     * container id and it is being dragged over any item in the over container
     * or the over container itself
     */
    function isSortingContainers({
      activeId,
      overId
    }: {
      activeId: string;
      overId: string;
    }) {
      const isActiveContainer = containerIds.includes(activeId);
      const isOverContainer =
        findContainer(overId) || containerIds.includes(overId);
      return !!isActiveContainer && !!isOverContainer;
    }
  
    function handleDragStart(event: DragStartEvent) {
      const { active } = event;
      const activeId = active.id as string;
  
      if (containerIds.includes(activeId)) {
        //set the state of active item if we are dragging a container
        const container = sortables.find((i) => i.id === activeId);
        if (container) setActiveItem(container);
      } else {
        //set the state of active item if we are dragging a container item
        const containerId = findContainer(activeId);
        const container = sortables.find((i) => i.id === containerId);
        const item = container?.items.find((i) => i.id === activeId);
        if (item) setActiveItem(item);
      }
    }
  
    /*In this function we handle when a sortable item is dragged from one container 
    to another container, to do this we need to know:
     - what item is being dragged 
     - what container it is being dragged from
     - what container it is being dragged to
     - what index to insert the active item into, in the new container
     */
    function handleDragOver(event: DragOverEvent) {
      const { active, over } = event;
      if (!active || !over) return;
      const activeId = active.id as string;
      const overId = over.id as string;
      //find the container id of the active item and the container id of the item being dragged over
      const activeContainerId = findContainer(activeId);
      const overContainerId = findContainer(overId);
      if (!overContainerId || !activeContainerId) return;
  
      //we don't want to sort containers, so we return early if we are sorting containers
      if (isSortingContainers({ activeId, overId })) return;
  
      //we only want to update the state if we are dragging over a different container
      if (activeContainerId !== overContainerId) {
        const activeContainer = sortables.find((i) => i.id === activeContainerId);
        const overContainer = sortables.find((i) => i.id === overContainerId);
        const activeItems = activeContainer?.items || [];
        const activeIndex = activeItems.findIndex((i) => i.id === activeId);
        const overItems = overContainer?.items || [];
        const overIndex = sortables.findIndex((i) => i.id === overId);
        let newIndex: number;
        if (containerIds.includes(overId)) {
          //if the container is empty, and we drag over it, the overId would be the id of that container and not
          //the id of any of its items since it is empty so we want to add the item to the end of
          //the container basically making it the last item
          newIndex = overItems.length + 1;
        } else {
          //Get the new index of the item being dragged over if it is a sortable item in the over container
          const isBelowOverItem =
            over &&
            active.rect.current.translated &&
            active.rect.current.translated.top > over.rect.top + over.rect.height;
          const modifier = isBelowOverItem ? 1 : 0;
          newIndex = overIndex >= 0 ? overIndex + modifier : overItems.length + 1;
        }
  
        //Update the state
        const newItems = sortables.map((item) =>
          // Remove the active item from the old list
          item.id === activeContainerId
            ? {
                ...item,
                items: activeItems.filter((item) => item.id !== active.id)
              }
            : // Add the active item to the new list
            item.id === overContainerId
            ? {
                ...item,
                items: [
                  ...item.items.slice(0, newIndex),
                  activeItems[activeIndex],
                  ...overItems.slice(newIndex, item.items.length)
                ]
              }
            : item
        );
  
        setSortables(newItems);
      }
    }
  
    /*In this function we handle when a sortable item is sorted within its container
      or when a sortable container is sorted with other sortable container
     */
    function handleDragEnd(event: DragEndEvent) {
      const { active, over } = event;
      if (!active || !over) return;
      const activeId = active.id as string;
      const overId = over.id as string;
      const activeContainerId = findContainer(activeId);
      const overContainerId = findContainer(overId);
  
      if (isSortingContainers({ activeId, overId })) {
        if (activeId !== overId) {
          //update sortable containers to their new positions
          setSortables((items) => {
            const oldIndex = sortables.findIndex(
              (f) => f.id === activeContainerId
            );
            const newIndex = sortables.findIndex((f) => f.id === overContainerId);
            return arrayMove(items, oldIndex, newIndex);
          });
        }
      }
  
      if (activeContainerId === overContainerId) {
        const activeContainer = sortables.find((i) => i.id === activeContainerId);
        const activeItems = activeContainer?.items || [];
        const oldIndex = activeItems.findIndex((i) => i.id === activeId);
        const newIndex = activeItems.findIndex((i) => i.id === overId);
        //update sortable items to their new positions
        const newItems = sortables.map((s) =>
          s.id === activeContainerId
            ? {
                ...s,
                items: arrayMove(s.items, oldIndex, newIndex)
              }
            : s
        );
  
        if (oldIndex !== newIndex) {
          setSortables(newItems);
        }
      }
    }
  }

Closing Thoughts

I hope I managed to demonstrate the power of DndKit. Its strength lies in its flexibility. You can use it to create anything from a simple to-do list to a complex sortable interface.

I hope you enjoyed this post. If you have any questions, you can reach me on Twitter @kxlaa_ and I'll be happy to answer them.

Thank you for reading and happy coding!