without dnd-kit, using the native HTML5 API
External DnD libraries solve problems that do not exist in many projects, while bringing along bundle size, learning overhead and dependencies of their own. The native HTML5 Drag and Drop API is entirely sufficient for sortable lists, file upload and simple drag interactions, wrapped in a custom hook under 100 lines of TypeScript.
Table of Contents
- 1. Why go native instead of a library at all?
- 2. The HTML5 Drag and Drop API explained
- 3. DnD events in React: quirks and pitfalls
- 4. Sortable list: step by step
- 5. useSortable: DnD logic wrapped in a custom hook
- 6. File upload with drag and drop
- 7. Visual feedback: ghost, overlay and drop indicator
- 8. Touch support and mobile limitations
- 9. Native vs. dnd-kit: which choice, when?
- 10. Summary
- 11. FAQ
1. Why go native instead of a library at all?
External drag-and-drop libraries such as dnd-kit or react-dnd solve real problems: complex drag channels between different lists, keyboard support for accessibility, touch support via the Pointer Events API, virtualized lists with thousands of entries, and complex drag-overlay logic. For those requirements they are excellent tools. For a simple task manager, a sortable image gallery, or a file upload with drag support, they are overengineering, adding several kilobytes of extra bundle size, a non-trivial learning curve, and an abstraction layer that makes simple customizations harder.
The HTML5 Drag and Drop API has been available in all modern browsers since HTML5 and can be used entirely without a JavaScript library. For React applications that means: a custom hook under a hundred lines, no external dependencies, no breaking changes from library updates, and direct control over every aspect of drag behavior. It is not the right choice in every project, but in more projects than you would think.
The decision to go with native DnD should be made deliberately. The native events have known quirks (no touch support, a non-intuitive event order, dragenter and dragleave firing on child elements). These pitfalls are documented and solvable, but you have to know them. This article covers exactly these quirks and shows how to handle them cleanly in React projects.
2. The HTML5 Drag and Drop API explained
The HTML5 Drag and Drop API consists of seven events split across two different elements: the drag element (the element being dragged) and the drop-zone element (the target). The drag element fires dragstart (drag begins), drag (during the drag, very frequent) and dragend (drag ends). The drop zone fires dragenter (drag enters the zone), dragover (drag moves over the zone), dragleave (drag leaves the zone) and drop (release over the zone).
The DataTransfer object is the communication medium between the drag element and the drop zone. In the dragstart handler you write data with event.dataTransfer.setData('text/plain', data), and in the drop handler you read it with event.dataTransfer.getData('text/plain'). For React applications it is often simpler to keep the data to be transferred in a ref instead of serializing and deserializing it through DataTransfer.
// sortable-list.tsx - Basic sortable list with native HTML5 DnD
import { useState, useRef, type DragEvent } from 'react';
interface Item { id: string; label: string; }
function SortableList({ initialItems }: { initialItems: Item[] }) {
const [items, setItems] = useState<Item[]>(initialItems);
// Store dragged item index in a ref, no re-render needed
const draggedIndex = useRef<number | null>(null);
const dragOverIndex = useRef<number | null>(null);
function handleDragStart(e: DragEvent<HTMLLIElement>, index: number) {
draggedIndex.current = index;
// Required for Firefox, must call setData, content doesn't matter
e.dataTransfer.setData('text/plain', String(index));
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e: DragEvent<HTMLLIElement>, index: number) {
e.preventDefault(); // Required to allow drop
e.dataTransfer.dropEffect = 'move';
dragOverIndex.current = index;
}
function handleDrop(e: DragEvent<HTMLLIElement>) {
e.preventDefault();
const from = draggedIndex.current;
const to = dragOverIndex.current;
if (from === null || to === null || from === to) return;
// Immutable array reorder
const next = [...items];
const [removed] = next.splice(from, 1);
next.splice(to, 0, removed);
setItems(next);
draggedIndex.current = null;
dragOverIndex.current = null;
}
function handleDragEnd() {
draggedIndex.current = null;
dragOverIndex.current = null;
}
return (
<ul className="space-y-2">
{items.map((item, index) => (
<li
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
className="flex items-center gap-3 px-4 py-3 bg-white border border-slate-200 rounded-xl cursor-grab active:cursor-grabbing select-none"
>
<span className="text-slate-400">⋮⋮</span>
{item.label}
</li>
))}
</ul>
);
}
3. DnD events in React: quirks and pitfalls
React synthesizes drag events just like every other DOM event. The most important difference from plain JavaScript: every React event handler must be passed as a JSX prop (onDragStart, onDragOver, etc.), not with addEventListener. That is not a surprise for most events, but it has an important consequence for drag and drop: React uses event delegation on the root element, which means events always rely on bubbling.
The best-known pitfall of the HTML5 DnD API concerns dragenter and dragleave: when a drag element moves over a child element, dragleave fires for the parent element and, immediately afterward, dragenter fires for that same parent element again (because of bubbling). That produces a visual flicker of the drop-zone highlight. The solution is a counter approach: increment a counter on dragenter, decrement it on dragleave. Only once the counter reaches zero has the drag truly left the zone. This counter can be kept in a ref to avoid re-renders.
4. Sortable list: step by step
A complete sortable list needs visual feedback in addition to the drag logic: the dragged card should become semi-transparent, and the potential drop position should be marked with an indicator. The easiest way to achieve the second detail with the native DnD API is via CSS classes that are set based on the dragOverIndex state. A data-drag-over attribute on the respective list element plus a CSS selector [data-drag-over] { border-top: 2px solid ... } is enough for simple visual feedback.
The opacity feedback for the dragged element is a bit trickier. You could use state (const [draggedId, setDraggedId] = useState(null)), but that triggers re-renders of every list element on every drag start and drag end. It is better to use a ref combined with direct DOM manipulation inside the event handler: event.currentTarget.style.opacity = '0.5' in dragstart and reset it in dragend. That is a direct intervention in the DOM, but acceptable for short-lived visual states during a drag.
// use-sortable.ts - Custom Hook encapsulating all DnD list logic
import { useState, useRef, useCallback, type DragEvent } from 'react';
interface SortableItem { id: string; [key: string]: unknown; }
interface UseSortableReturn<T> {
items: T[];
getDragProps: (index: number) => {
draggable: true;
onDragStart: (e: DragEvent<HTMLElement>) => void;
onDragOver: (e: DragEvent<HTMLElement>) => void;
onDrop: (e: DragEvent<HTMLElement>) => void;
onDragEnd: () => void;
'data-drag-index': number;
};
dragOverIndex: number | null;
}
export function useSortable<T extends SortableItem>(initialItems: T[]): UseSortableReturn<T> {
const [items, setItems] = useState<T[]>(initialItems);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
const getDragProps = useCallback((index: number) => ({
draggable: true as const,
'data-drag-index': index,
onDragStart: (e: DragEvent<HTMLElement>) => {
draggedIndexRef.current = index;
e.dataTransfer.setData('text/plain', String(index));
e.dataTransfer.effectAllowed = 'move';
// Slight delay so browser captures element before opacity change
requestAnimationFrame(() => {
(e.target as HTMLElement).style.opacity = '0.4';
});
},
onDragOver: (e: DragEvent<HTMLElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== index) setDragOverIdx(index);
},
onDrop: (e: DragEvent<HTMLElement>) => {
e.preventDefault();
const from = draggedIndexRef.current;
if (from === null || from === index) return;
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
return next;
});
setDragOverIdx(null);
draggedIndexRef.current = null;
},
onDragEnd: () => {
setDragOverIdx(null);
draggedIndexRef.current = null;
// Reset opacity on all items
document.querySelectorAll('[data-drag-index]').forEach(el => {
(el as HTMLElement).style.opacity = '';
});
},
}), [index, dragOverIdx]);
return { items, getDragProps, dragOverIndex: dragOverIdx };
}
6. File upload with drag and drop
File upload with drag and drop is a common use case that can be implemented entirely without external libraries. The drop zone is a div with the event handlers onDragOver, onDragEnter, onDragLeave and onDrop. In the drop handler, the dropped files are available as a FileList via event.dataTransfer.files. The important difference from sortable lists: for file drops you must call event.preventDefault() in the dragover handler, otherwise the browser opens the file instead of dropping it.
Validation of the dropped files (type, size) happens directly in the drop handler via the file properties file.type and file.size. For a better UX you can already check in the dragenter handler whether the DataTransfer items match the accepted MIME types, which is possible via event.dataTransfer.items without having to read the files themselves. That allows immediate visual feedback (a green drop zone for acceptable files, red for unacceptable ones).
7. Visual feedback: ghost, overlay and drop indicator
The native HTML5 DnD API automatically generates a screenshot of the dragged element as a drag ghost. This ghost can be replaced with event.dataTransfer.setDragImage(element, offsetX, offsetY). A typical pattern: create an absolutely positioned, visually customized element outside the viewport (position: absolute; left: -9999px), pass it as the drag image, and remove it after the drag. In React this is done conveniently via a ref to a pre-rendered ghost container.
Drop indicators between list items (a horizontal bar marking the insertion position) are noticeably more work with the native API than with dnd-kit. The simplest solution: calculate the mouse pointer's Y position relative to the list item in the dragover handler (event.clientY - rect.top) and decide whether the position falls in the upper or lower half. Then set a data-drop-position="before"|"after" attribute that shows the bar via CSS. That is a bit more computation than with a library, but fully solvable without external dependencies.
8. Touch support and mobile limitations
The native HTML5 Drag and Drop API does not work on touch devices. That is the most important limitation and the main reason why libraries such as dnd-kit make sense in many projects: they implement touch support via the Pointer Events API or Touch Events. For projects where drag and drop is only relevant on desktop (admin panels, desktop applications) this is not a problem. For consumer applications with mobile traffic it is a disqualifying factor for native DnD.
A pragmatic solution for projects with mixed traffic: native DnD for desktop, alternative interactions for mobile. Instead of drag-to-sort on mobile you can offer arrow buttons for reordering, which are simpler to implement, more accessible, and feel more natural for touch interactions. This progressive-enhancement strategy is often the better user experience than touch-drag simulations, which tend to feel unnatural on mobile devices.
9. Native vs. dnd-kit: which choice, when?
The decision between the native HTML5 DnD API and a library such as dnd-kit should depend on the concrete requirements of the project, not on a habit of always choosing the most powerful or always the simplest solution.
| Criterion | Native HTML5 DnD | dnd-kit |
|---|---|---|
| Touch support | Not available | Fully supported via Pointer Events |
| Bundle size | 0 KB | ~28 KB gzip (with Sortable) |
| Keyboard accessibility | Implement manually | Built in (ARIA, keyboard) |
| Virtualized lists | Complex | Supported via @dnd-kit/sortable |
| Drop indicators | Implement manually | Automatic via overlay |
| File upload | Direct via dataTransfer.files | Not a primary use case |
The practical decision rule: if the application is used on mobile and DnD matters there, or if keyboard accessibility is a requirement, or if virtualized lists with thousands of entries need to be sortable, then dnd-kit is the right choice. For desktop admin panels, simple file-upload zones, and sortable lists with few entries and no mobile requirement, the native HTML5 API is entirely sufficient and the simpler choice.