React Drag and Drop Without External Libraries
Drag-and-drop is one of those interactions users expect to "just work." Whether it is reordering a task board, uploading files by dragging them onto a page, or letting users rearrange widgets on a dashboard, the ability to grab something and move it feels natural. Yet most React tutorials immediately reach for heavyweight libraries like react-dnd or dnd-kit -- packages that are powerful but add significant bundle size and conceptual overhead for many common use cases.
What if you could get smooth, production-ready drag-and-drop behavior with a single hook call? In this post we will start from raw browser APIs, see why they are painful, and then solve the same problems with two lightweight hooks from ReactUse (@reactuses/core): useDraggable and useDropZone. ReactUse is an open-source collection of 100+ React hooks that wrap common browser and element interactions into simple, composable APIs.
The Manual Approach: Pointer Events by Hand
The most basic way to make an element draggable is to listen for pointerdown, pointermove, and pointerup yourself. Here is what that typically looks like:
import { useEffect, useRef, useState } from "react";
function ManualDraggable() {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const delta = useRef({ x: 0, y: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const onPointerDown = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
delta.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
setIsDragging(true);
};
const onPointerMove = (e: PointerEvent) => {
if (!isDragging) return;
setPosition({
x: e.clientX - delta.current.x,
y: e.clientY - delta.current.y,
});
};
const onPointerUp = () => setIsDragging(false);
el.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
return () => {
el.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
}, [isDragging]);
return (
<div
ref={ref}
style={{
position: "fixed",
left: position.x,
top: position.y,
cursor: isDragging ? "grabbing" : "grab",
padding: 16,
background: "#4f46e5",
color: "#fff",
borderRadius: 8,
}}
>
Drag me
</div>
);
}
It works -- but look at the amount of state you have to manage. And this is the simple version. Real-world requirements quickly pile on more complexity.
Why Manual Drag-and-Drop is Hard
The snippet above has several shortcomings that surface the moment you move beyond a demo:
Container bounds. If you want the element to stay inside a parent container, you need to read the container dimensions on every move and clamp the position. That means calling
getBoundingClientRecton two elements each frame.Pointer types. The code above handles mouse events, but what about touch or pen? The
PointerEventAPI unifies them, yet filtering by pointer type (e.g., disabling drag for pen) requires extra conditionals.Drag handles. Sometimes the draggable surface is only a small title bar inside a card. You need to split the "trigger" element from the "moving" element and wire the events accordingly.
Event cleanup. Forgetting to remove listeners -- or adding them with the wrong dependencies in
useEffect-- causes subtle bugs like elements that keep moving after you release the mouse.Drop zones. The HTML5 Drag and Drop API introduces
dragenter,dragover,dragleave, anddrop. Coordinating these events -- especially the notoriousdragenter/dragleaveflickering on child elements -- is error-prone.
These are exactly the problems that useDraggable and useDropZone solve out of the box.
useDraggable: One Hook, Full Control
useDraggable takes a ref to your target element and an optional configuration object. It returns the current x and y position, a boolean indicating whether the element is being dragged, and a setter in case you need to move the element programmatically.
import { useDraggable } from "@reactuses/core";
import { useRef } from "react";
function DraggableCard() {
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging] = useDraggable(el, {
initialValue: { x: 100, y: 100 },
});
return (
<div
ref={el}
style={{
position: "fixed",
left: x,
top: y,
cursor: isDragging ? "grabbing" : "grab",
padding: 16,
background: isDragging ? "#4338ca" : "#4f46e5",
color: "#fff",
borderRadius: 8,
transition: isDragging ? "none" : "box-shadow 0.2s",
boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.2)" : "none",
userSelect: "none",
touchAction: "none",
}}
>
Drag me anywhere
</div>
);
}
That is the entire component. No manual event listeners. No cleanup logic. Touch, mouse, and pen input work by default.
Constraining to a Container
Pass a containerElement ref and the hook automatically clamps the position so the element cannot leave the container:
import { useDraggable } from "@reactuses/core";
import { useRef } from "react";
function BoundedDrag() {
const container = useRef<HTMLDivElement>(null);
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging] = useDraggable(el, {
containerElement: container,
initialValue: { x: 0, y: 0 },
});
return (
<div
ref={container}
style={{
position: "relative",
width: 400,
height: 300,
border: "2px dashed #cbd5e1",
borderRadius: 8,
}}
>
<div
ref={el}
style={{
position: "absolute",
left: x,
top: y,
width: 80,
height: 80,
background: "#4f46e5",
borderRadius: 8,
cursor: isDragging ? "grabbing" : "grab",
touchAction: "none",
}}
/>
</div>
);
}
No manual clamping math. The hook reads the container's scroll and client dimensions and restricts the element automatically.
Using a Drag Handle
Often you want only a specific part of an element -- like a header bar -- to trigger dragging. Pass a handle ref:
import { useDraggable } from "@reactuses/core";
import { useRef } from "react";
function DraggablePanel() {
const panel = useRef<HTMLDivElement>(null);
const handle = useRef<HTMLDivElement>(null);
const [x, y, isDragging] = useDraggable(panel, {
handle,
initialValue: { x: 200, y: 150 },
});
return (
<div
ref={panel}
style={{
position: "fixed",
left: x,
top: y,
width: 280,
background: "#fff",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
overflow: "hidden",
touchAction: "none",
}}
>
<div
ref={handle}
style={{
padding: "8px 12px",
background: "#4f46e5",
color: "#fff",
cursor: isDragging ? "grabbing" : "grab",
userSelect: "none",
}}
>
Drag from here
</div>
<div style={{ padding: 12 }}>
<p>This content area does not trigger a drag.</p>
</div>
</div>
);
}
The body of the panel remains interactive -- you can select text, click buttons, or scroll -- while the header is the only drag trigger.
useDropZone: File Drops Made Easy
useDropZone tackles the other half of the drag-and-drop story: receiving drops. It handles all four drag events (dragenter, dragover, dragleave, drop), suppresses the browser's default file-opening behavior, and solves the flickering dragleave problem using an internal counter.
import { useDropZone } from "@reactuses/core";
import { useRef, useState } from "react";
function FileUploader() {
const dropRef = useRef<HTMLDivElement>(null);
const [files, setFiles] = useState<File[]>([]);
const isOver = useDropZone(dropRef, (droppedFiles) => {
if (droppedFiles) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
});
return (
<div
ref={dropRef}
style={{
padding: 40,
border: `2px dashed ${isOver ? "#4f46e5" : "#cbd5e1"}`,
borderRadius: 8,
background: isOver ? "#eef2ff" : "#f8fafc",
textAlign: "center",
transition: "all 0.15s",
}}
>
{isOver ? (
<p>Release to upload</p>
) : (
<p>Drag files here to upload</p>
)}
{files.length > 0 && (
<ul style={{ textAlign: "left", marginTop: 16 }}>
{files.map((f, i) => (
<li key={i}>
{f.name} ({(f.size / 1024).toFixed(1)} KB)
</li>
))}
</ul>
)}
</div>
);
}
The isOver boolean lets you restyle the zone the instant a file enters, giving users clear visual feedback. No e.preventDefault() boilerplate, no fighting with flickering dragleave events.
Building a Kanban-Style Card Mover
Let's combine both hooks in a more realistic example -- a draggable card that snaps back when released and a drop zone that accepts it. We will also use useElementBounding to read zone positions for visual feedback.
import { useDraggable, useDropZone, useElementBounding } from "@reactuses/core";
import { useRef, useState } from "react";
interface Task {
id: string;
title: string;
}
function KanbanBoard() {
const [todo, setTodo] = useState<Task[]>([
{ id: "1", title: "Design mockups" },
{ id: "2", title: "Write API spec" },
]);
const [done, setDone] = useState<Task[]>([
{ id: "3", title: "Set up CI pipeline" },
]);
const doneZoneRef = useRef<HTMLDivElement>(null);
const todoZoneRef = useRef<HTMLDivElement>(null);
const isOverDone = useDropZone(doneZoneRef, (files) => {
// File drops are ignored in this example
});
const isOverTodo = useDropZone(todoZoneRef, (files) => {
// File drops are ignored in this example
});
const doneBounds = useElementBounding(doneZoneRef);
return (
<div style={{ display: "flex", gap: 24, padding: 24 }}>
<div>
<h3>To Do</h3>
<div
ref={todoZoneRef}
style={{
minHeight: 200,
padding: 12,
background: isOverTodo ? "#fef3c7" : "#f1f5f9",
borderRadius: 8,
}}
>
{todo.map((task) => (
<TaskCard
key={task.id}
task={task}
onDrop={() => {
setTodo((prev) => prev.filter((t) => t.id !== task.id));
setDone((prev) => [...prev, task]);
}}
targetBounds={doneBounds}
/>
))}
</div>
</div>
<div>
<h3>Done</h3>
<div
ref={doneZoneRef}
style={{
minHeight: 200,
padding: 12,
background: isOverDone ? "#d1fae5" : "#f1f5f9",
borderRadius: 8,
}}
>
{done.map((task) => (
<div
key={task.id}
style={{
padding: 12,
marginBottom: 8,
background: "#fff",
borderRadius: 6,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
{task.title}
</div>
))}
</div>
</div>
</div>
);
}
function TaskCard({
task,
onDrop,
targetBounds,
}: {
task: Task;
onDrop: () => void;
targetBounds: ReturnType<typeof useElementBounding>;
}) {
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging, setPosition] = useDraggable(el, {
initialValue: { x: 0, y: 0 },
onEnd: (pos) => {
// Check if the card was released over the "Done" column
if (
targetBounds &&
pos.x >= targetBounds.left &&
pos.x <= targetBounds.right &&
pos.y >= targetBounds.top &&
pos.y <= targetBounds.bottom
) {
onDrop();
}
// Snap back to original position
setPosition({ x: 0, y: 0 });
},
});
return (
<div
ref={el}
style={{
position: "relative",
left: x,
top: y,
padding: 12,
marginBottom: 8,
background: isDragging ? "#e0e7ff" : "#fff",
borderRadius: 6,
boxShadow: isDragging
? "0 8px 24px rgba(0,0,0,0.15)"
: "0 1px 3px rgba(0,0,0,0.1)",
cursor: isDragging ? "grabbing" : "grab",
zIndex: isDragging ? 50 : 1,
touchAction: "none",
userSelect: "none",
transition: isDragging ? "none" : "all 0.2s ease",
}}
>
{task.title}
</div>
);
}
Key details worth noting:
-
useElementBoundinggives us liveleft,right,top, andbottomvalues for the "Done" column so we can hit-test when the drag ends. - The
onEndcallback snaps the card back to{ x: 0, y: 0 }if it was not dropped on the target. This creates a satisfying rubber-band effect with the CSStransition. - No external state library is needed. React's
useStateis sufficient for this level of complexity.
Enhancing the Experience with Companion Hooks
ReactUse's hooks compose naturally. Here are a few ways to extend the examples above:
-
useMouse-- Track the cursor globally to show a custom drag cursor or a floating tooltip that follows the pointer during a drag operation. -
useEventListener-- Attach a one-offkeydownlistener to cancel a drag when the user presses Escape. -
useElementSize-- Dynamically read the width and height of a container to calculate snap-to-grid positions (e.g., roundxto the nearest multiple of the cell width).
For example, adding Escape-to-cancel is just a few lines with useEventListener:
import { useDraggable, useEventListener } from "@reactuses/core";
import { useRef } from "react";
function CancelableDrag() {
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging, setPosition] = useDraggable(el);
useEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape" && isDragging) {
setPosition({ x: 0, y: 0 });
}
});
return (
<div
ref={el}
style={{
position: "fixed",
left: x,
top: y,
padding: 16,
background: "#4f46e5",
color: "#fff",
borderRadius: 8,
cursor: isDragging ? "grabbing" : "grab",
touchAction: "none",
}}
>
Drag me (press Esc to reset)
</div>
);
}
When to Still Use a Full Library
useDraggable and useDropZone cover the vast majority of drag-and-drop use cases with minimal code. However, if your requirements include complex reorderable lists with animated transitions, multi-container sorting with keyboard accessibility, or virtualized lists with thousands of items, a dedicated library like dnd-kit remains the better choice. The key insight is that you don't need one for every situation -- and for many projects, a pair of hooks is all it takes.
Installation
npm i @reactuses/core
Related Hooks
-
useDraggable-- Make any element draggable with pointer events -
useDropZone-- Create drop zones for file uploads and drag operations -
useElementBounding-- Get live bounding rectangle of an element -
useMouse-- Track mouse position globally -
useEventListener-- Attach event listeners declaratively -
useElementSize-- Track element dimensions reactively
ReactUse provides 100+ hooks for React. Explore them all →
Originally published on ReactUse Blog
Top comments (0)