Drag-and-Drop Sortable Lists in React: No Library Required
Build a fully functional drag-and-drop sortable list in React using only the HTML5 Drag API — no dnd-kit, no react-beautiful-dnd. Just clean hooks and Tailwind v4.
Why You Don't Need a Library for This
Honestly, adding a 40kb drag-and-drop library to reorder five items in a list is overkill. dnd-kit is great. react-beautiful-dnd was great before it got abandoned. But for a huge chunk of real-world use cases — task lists, preference panels, sidebar navigation — you're carrying a lot of weight for something the browser can handle natively.
The HTML5 Drag and Drop API has been around forever and it works everywhere that matters. Yes, it has some rough edges on mobile. Yes, the default drag ghost is ugly. But we can fix both of those things ourselves without pulling in another dependency tree.
This article walks through building a fully functional sortable list component in React using only useState, useRef, and the native drag events. We're using Tailwind v4.0.2 for styling — no custom CSS files, no extra configuration. If you want to see how a polished version looks alongside other interactive components, check out the animated tabs component for a similar interaction-heavy pattern built the same way.
Understanding the HTML5 Drag Events You Actually Need
The spec defines about a dozen drag events but you only need five of them. onDragStart fires when the user picks up an element. onDragOver fires continuously as the dragged item moves over a potential drop target — you must call e.preventDefault() here or the drop simply won't fire. onDrop is where you do the actual reordering. onDragEnter and onDragLeave are useful for visual feedback.
The key mental model: there are two actors — the drag source (what's being moved) and the drop target (what it's hovering over). In a sortable list, every item is simultaneously both. That's what makes it slightly tricky. You need to track which item is being dragged and which item is currently being hovered so you can compute the new order in real time.
One gotcha that trips people up: onDragLeave fires when the cursor moves over a child element inside your list item. That means your hover highlight flickers constantly. The fix is comparing e.relatedTarget against the container, or using a dragCounter ref. We'll use the ref approach — it's more explicit.
Setting Up State and Refs
Here's the full hook. Drop this in hooks/useSortableList.ts and you can reuse it anywhere.
import { useState, useRef } from 'react';
export function useSortableList<T>(initialItems: T[]) {
const [items, setItems] = useState<T[]>(initialItems);
const dragIndex = useRef<number | null>(null);
const dragCounter = useRef<Record<number, number>>({});
const [overIndex, setOverIndex] = useState<number | null>(null);
const handleDragStart = (index: number) => {
dragIndex.current = index;
};
const handleDragEnter = (index: number) => {
dragCounter.current[index] = (dragCounter.current[index] ?? 0) + 1;
setOverIndex(index);
};
const handleDragLeave = (index: number) => {
dragCounter.current[index] = (dragCounter.current[index] ?? 1) - 1;
if (dragCounter.current[index] <= 0) {
dragCounter.current[index] = 0;
setOverIndex((prev) => (prev === index ? null : prev));
}
};
const handleDrop = (dropIndex: number) => {
const from = dragIndex.current;
if (from === null || from === dropIndex) {
dragIndex.current = null;
setOverIndex(null);
return;
}
setItems((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(dropIndex, 0, moved);
return next;
});
dragIndex.current = null;
dragCounter.current = {};
setOverIndex(null);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault(); // required for drop to fire
};
const handleDragEnd = () => {
dragIndex.current = null;
dragCounter.current = {};
setOverIndex(null);
};
return {
items,
overIndex,
dragIndex,
handlers: {
handleDragStart,
handleDragEnter,
handleDragLeave,
handleDrop,
handleDragOver,
handleDragEnd,
},
};
}The dragCounter map tracks enter/leave depth per item index. Each DragEnter increments; each DragLeave decrements. We only clear the overIndex when the counter reaches zero, which means we've genuinely left the item's bounds. This eliminates the flicker completely.
Building the SortableList Component with Tailwind v4
Now wire the hook into a component. We're using Tailwind v4.0.2 utility classes directly — gap-2, rounded-xl, transition-transform, duration-150. The drag handle is a set of three horizontal lines rendered with <span> elements spaced 4px apart. Simple and accessible.
import { useSortableList } from '@/hooks/useSortableList';
type Item = { id: string; label: string };
export function SortableList({ initialItems }: { initialItems: Item[] }) {
const { items, overIndex, dragIndex, handlers } = useSortableList(initialItems);
const {
handleDragStart,
handleDragEnter,
handleDragLeave,
handleDrop,
handleDragOver,
handleDragEnd,
} = handlers;
return (
<ul className="flex flex-col gap-2 p-4 max-w-md mx-auto">
{items.map((item, index) => {
const isDragging = dragIndex.current === index;
const isOver = overIndex === index;
return (
<li
key={item.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragEnter={() => handleDragEnter(index)}
onDragLeave={() => handleDragLeave(index)}
onDrop={() => handleDrop(index)}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
className={[
'flex items-center gap-3 px-4 py-3 rounded-xl',
'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700',
'cursor-grab select-none transition-all duration-150',
isDragging ? 'opacity-40 scale-[0.98]' : 'opacity-100',
isOver && !isDragging
? 'border-indigo-500 shadow-[0_0_0_2px_rgba(99,102,241,0.35)]'
: '',
]
.filter(Boolean)
.join(' ')}
>
<span className="flex flex-col gap-[4px] shrink-0">
{[0, 1, 2].map((i) => (
<span
key={i}
className="block w-4 h-[2px] rounded-full bg-zinc-400"
/>
))}
</span>
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
{item.label}
</span>
</li>
);
})}
</ul>
);
}The shadow-[0_0_0_2px_rgba(99,102,241,0.35)] class is Tailwind's arbitrary value syntax — it draws an inset ring in indigo at 35% opacity around the hovered item. Clean visual feedback without touching a CSS file. The scale-[0.98] on the dragging item gives a subtle 'picked up' feeling.
Animating the Placeholder Gap
One thing that makes sorting feel good is a live preview of where the item will land. Libraries like dnd-kit do this by computing pixel offsets and applying transforms. We can get 80% of the way there with a simpler trick: inserting a placeholder <li> at the overIndex position while dragging.
Wrap the items render with a check: if dragIndex.current !== null and index === overIndex, render the actual item plus a placeholder <li> before it with height: 52px (matching the item height) and border-dashed border-2 border-indigo-400 rounded-xl bg-indigo-50 dark:bg-indigo-950. This visually separates the insertion point from the hover highlight approach.
Both approaches work. The placeholder feels more like a 'slot' and is easier to understand at a glance. The ring highlight is subtler and works better in dense lists. Pick what fits your UI — and if you're building something with more complex layout needs, see how bento grid layouts handle reordering across different cell sizes, which requires a grid-aware approach instead.
Persisting Order and Controlling State
Right now the sorted order lives inside the hook. For most use cases you'll want to lift that state up — either into a parent component or a state manager. The hook accepts initialItems but doesn't own the source of truth forever. Swap useState for a controlled pattern by accepting items and onReorder as props instead.
For persistence, call your API in handleDrop after the state update. Something like await fetch('/api/preferences/order', { method: 'PATCH', body: JSON.stringify({ ids: next.map(i => i.id) }) }). You can optimistically update the UI immediately (which is what the setItems call does) and roll back on error. That's the standard pattern.
If you're using Zustand or Jotai, the setItems call inside handleDrop becomes a store dispatch. The drag handler logic stays exactly the same. Worth noting: if your list can have items added or removed by other parts of the UI, make sure you're computing dragIndex as an item ID reference, not a positional index — stale indexes cause subtle bugs when the list length changes mid-drag.
Mobile Touch Support Without a Library
Here's the thing: the HTML5 Drag API genuinely does not work on iOS Safari. It fires zero touch events. Android Chrome has partial support but it's unreliable. If your sortable list needs to work on mobile, you have two options — reach for a library or roll your own pointer event handlers.
The pointer event approach isn't as scary as it sounds. On pointerdown, record the start position and item index. On pointermove, compute which list item the pointer is currently over by calling document.elementFromPoint(e.clientX, e.clientY). On pointerup, apply the reorder. Wrap the whole thing in setPointerCapture so you don't lose the event stream when the pointer leaves the element bounds.
Is it more code? Yes. But it's maybe 60 extra lines and you own every pixel of it. For a polished interactive UI that also needs animated button feedback on the drag handles, this level of control over pointer events pairs really well. You can fire haptic feedback on mobile with navigator.vibrate(10) on pointerdown — a tiny touch that makes the interaction feel physical.
Accessibility: Making Keyboard Sorting Work
Drag and drop is inherently inaccessible to keyboard-only users. You need a parallel keyboard interaction. The pattern: when an item has focus, Space or Enter 'picks it up', arrow keys move it, and Space/Enter again drops it in the new position. Pressing Escape cancels.
Implement this with a keyboardDragIndex state that's separate from your mouse drag logic. When keyboardDragIndex !== null, render a visible 'moving' indicator (a colored left border, say border-l-4 border-indigo-500) and announce the position change to screen readers via an aria-live region. Something like: 'Task moved to position 3 of 7.'
Add role="listbox" to the <ul> and role="option" plus aria-selected={isDragging} to each <li>. It's not a perfect semantic match but it's the closest ARIA pattern for a list where items can be reordered. If you're building a full design system, pairing this with a theme toggle component that respects prefers-reduced-motion is a good step — users who opt out of motion should get instant reordering instead of the 150ms transitions.
FAQ
You forgot to call e.preventDefault() inside onDragOver. The browser blocks drops by default. Every potential drop target must call e.preventDefault() in its onDragOver handler — even if it's the same element as the drag source.
Call e.dataTransfer.setDragImage(element, offsetX, offsetY) inside onDragStart. Pass it a cloned version of your list item (appended off-screen with position: fixed; left: -9999px) styled exactly how you want. This completely replaces the browser's default screenshot-style ghost.
It gets complicated. Virtualized lists unmount items outside the viewport, so dragIndex positions can shift as you scroll. You're better off using item IDs as the drag reference instead of array indexes, and recalculating position from the ID on drop. Some teams reach for dnd-kit specifically because it handles virtual list integration natively.
Set draggable={true} on the drag handle span only, not on the entire li element. Then set e.stopPropagation() on your button's onClick. If you need the whole row draggable but want buttons to stay clickable, add onMouseDown={e => e.preventDefault()} to the buttons — it prevents the drag from initiating on those targets.
You need shared state above both lists. Track the source list ID in a ref alongside the dragIndex. In onDrop, if the source list ID differs from the drop target's list ID, splice the item out of the source list and into the destination. Make sure both lists share the same onDragOver handler that calls e.preventDefault().
Yes, but your placeholder's height needs to match the dragged item's height dynamically. Grab the height in onDragStart via a ref: dragItemHeight.current = e.currentTarget.getBoundingClientRect().height. Then set the placeholder's height to that value in pixels instead of a hardcoded 52px.