react-headless-dropzone
v1.0.0
Published
A production-ready, fully headless React dropzone & file-upload library. Behaviour, validation, previews, an upload queue and full accessibility — zero styling, zero runtime dependencies.
Maintainers
Readme
react-headless-dropzone
A production-ready, fully headless React dropzone & file-upload library.
Behaviour, validation, previews, an upload queue and complete accessibility — with zero styling and zero runtime dependencies.
Why another dropzone?
react-headless-dropzone gives you both: a complete file-management engine
(validation, deduplication, previews, an abortable concurrent upload queue,
retries) exposed through a Radix-style headless API where you own 100% of
the markup and styling.
Features
- 🧲 Drag & drop, click-to-select, keyboard, touch and clipboard paste
- 📁 Directory drops & folder selection (recursive, with relative paths)
- ✅ Validation — type, size, min/max files, sync and async custom validators
- 🖼️ Lazy previews for images, video, audio and PDFs with automatic object-URL cleanup
- 🔁 Upload queue — concurrency limit, progress, cancel, retry,
AbortSignal - 🧬 Duplicate detection, file replacement, single-file mode
- ♿ Accessible by default — roles, ARIA, focus management, full keyboard support
- 🎛️ Controlled & uncontrolled modes
- 🧩 Two APIs — a powerful
useDropzonehook and composable<Dropzone.*>components - 🎨 Truly headless — every element takes
className/style(static or a function of state),asChild, render props, and richdata-*attributes - 🌳 Tree-shakeable, ESM + CJS, ships
.d.ts+ source maps,"use client"ready - ⚛️ React 18 & 19, Strict-Mode clean, SSR/RSC safe
Installation
npm install react-headless-dropzone
# or
pnpm add react-headless-dropzone
# or
yarn add react-headless-dropzonereact (>=18) is the only peer dependency.
Quick start
With the hook
import { useDropzone } from 'react-headless-dropzone';
function Uploader() {
const { getRootProps, getInputProps, acceptedFiles, isDragActive } = useDropzone({
accept: { 'image/*': ['.png', '.jpg', '.jpeg'] },
maxSize: 5 * 1024 * 1024,
});
return (
<div {...getRootProps()} className="dropzone">
<input {...getInputProps()} />
{isDragActive ? <p>Drop them here…</p> : <p>Click or drag files to upload</p>}
<ul>
{acceptedFiles.map((file) => (
<li key={file.id}>{file.name}</li>
))}
</ul>
</div>
);
}With compound components
Copy-paste ready, styled with Tailwind. This is exactly the code that produces the screenshot below — nothing omitted:
import { Dropzone } from 'react-headless-dropzone';
import { uploadFile } from './upload'; // your own handler — see "Uploading files"
export function Uploader() {
return (
<Dropzone
accept={{ 'image/*': [], 'application/pdf': ['.pdf'] }}
maxFiles={5}
autoUpload
onUpload={uploadFile}
className="relative rounded-xl border-2 border-dashed border-zinc-300 bg-white p-4 transition-colors data-drag-active:border-indigo-500 data-drag-active:bg-indigo-50 data-drag-reject:border-red-500 data-drag-reject:bg-red-50 data-focused:outline-2 data-focused:outline-offset-2 data-focused:outline-indigo-500"
>
<Dropzone.Input />
<Dropzone.Empty className="cursor-pointer p-8 text-center text-sm text-zinc-500">
<span className="font-medium text-zinc-900">Click to browse</span> or drag files here
</Dropzone.Empty>
<Dropzone.DragOverlay className="pointer-events-none absolute inset-0 grid place-items-center rounded-lg bg-indigo-500/10 font-semibold text-indigo-700">
Drop to upload
</Dropzone.DragOverlay>
<Dropzone.FileList as="ul" className="grid gap-2">
{(file) => (
<li
data-status={file.status}
className="flex items-center gap-3 rounded-lg border border-zinc-200 bg-white p-2.5 data-[status=error]:border-red-300 data-[status=error]:bg-red-50 data-[status=rejected]:border-red-300 data-[status=rejected]:bg-red-50 data-[status=success]:border-green-300"
>
<Dropzone.Preview
className="h-10 w-10 shrink-0 rounded-md object-cover"
fallback={
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-zinc-100 text-[10px] font-bold uppercase text-zinc-400">
{file.name.split('.').pop()}
</span>
}
/>
<div className="min-w-0 flex-1">
<Dropzone.FileName className="block truncate text-sm font-medium text-zinc-900" />
<Dropzone.FileSize className="text-xs text-zinc-500" />
{/* FileProgress exposes --rhd-progress (e.g. "62%") for the fill */}
<Dropzone.FileProgress className="mt-1.5 h-1 overflow-hidden rounded-full bg-zinc-100 after:block after:h-full after:w-(--rhd-progress) after:bg-indigo-500 after:transition-[width] data-[status=error]:after:bg-red-500 data-[status=success]:after:bg-green-500" />
<Dropzone.FileErrors className="mt-1 list-none p-0 text-xs text-red-600" />
</div>
<Dropzone.RemoveButton
aria-label={`Remove ${file.name}`}
className="shrink-0 rounded-md px-2 py-1 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
✕
</Dropzone.RemoveButton>
</li>
)}
</Dropzone.FileList>
</Dropzone>
);
}No CSS import needed — the classes above are plain Tailwind. Not using Tailwind? Every one of those
data-[…]variants is just a realdata-*attribute, so the same design works in vanilla CSS (.dropzone[data-drag-active] { … }) or any CSS-in-JS. See Styling and the showcase for ten more looks.
Demos
Ten designs, one component. Each of these is the same engine with different Tailwind classes — no forks, no variants, no theme config.
→ See all 10 examples with full source code ←
Also included: Minimal · Modern gradient · Landing hero · Avatar picker
Every screenshot is captured by a headless browser driving the real components (
npm run showcase), so the previews can never drift from actual behaviour. Run them yourself withnpm run dev→ Styling showcase.
Uploading files
Provide an onUpload handler. It receives an AbortSignal (for cancellation)
and an onProgress callback, and may resolve with any value or throw to fail.
const dz = useDropzone({
autoUpload: true, // start as soon as files are accepted
maxConcurrentUploads: 3,
onUpload: async ({ file, signal, onProgress }) => {
const body = new FormData();
body.append('file', file.file);
const res = await fetch('/api/upload', { method: 'POST', body, signal });
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
onProgress(100);
return res.json();
},
});
// Manual control:
dz.uploadAll();
dz.uploadFile(id);
dz.cancelUpload(id);
dz.retryUpload(id);Real progress with XMLHttpRequest:
onUpload: ({ file, signal, onProgress }) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100);
xhr.onload = () =>
xhr.status < 400 ? resolve(xhr.response) : reject(new Error(xhr.statusText));
xhr.onerror = () => reject(new Error('Network error'));
signal.addEventListener('abort', () => xhr.abort());
const body = new FormData();
body.append('file', file.file);
xhr.send(body);
});Styling
The library renders no styles of its own. Style it through any of these mechanisms, in whatever combination suits your stack (Tailwind, CSS Modules, Emotion, styled-components, vanilla CSS, …):
1. data-* state attributes on the root and every slot:
| Attribute | On | Meaning |
| --------------------------------------- | ------------------------ | -------------------------------------------------------------------------- |
| data-dropzone | root | marker |
| data-drag-active | root | a drag is over the zone |
| data-drag-accept / data-drag-reject | root | the drag would be (dis)allowed |
| data-focused | root | root has focus |
| data-disabled | root, buttons | disabled |
| data-uploading | root | an upload is in flight |
| data-status | file slots | ready | rejected | uploading | success | error | canceled |
| data-progress | FileProgress | 0–100 |
| --rhd-progress | FileProgress (CSS var) | e.g. 73% |
.dropzone[data-drag-active] {
border-color: dodgerblue;
}
[data-dropzone-file-progress]::after {
width: var(--rhd-progress);
}2. className / style as functions of state:
<Dropzone className={(s) => (s.isDragReject ? 'ring-red-500' : 'ring-gray-200')} />3. asChild to render your element (Radix Slot pattern):
<Dropzone.Trigger asChild>
<MyButton variant="primary">Browse…</MyButton>
</Dropzone.Trigger>4. Render props — every slot's children can be a function of state:
<Dropzone.FileStatus>
{({ file }) => (file.status === 'success' ? '✓ Uploaded' : file.status)}
</Dropzone.FileStatus>5. Go fully custom — ignore the components entirely and build your own UI on
top of useDropzone. The hook exposes everything.
Two ways to build
| | Hook (useDropzone) | Components (<Dropzone.*>) |
| ----------- | ------------------------------- | ----------------------------- |
| Control | Maximum — you render every node | High — compose provided slots |
| Boilerplate | More | Less |
| Best for | Bespoke designs, design systems | Getting productive fast |
Both share the exact same engine; you can even mix them via
<Dropzone dropzone={api}>.
Documentation
- 🎨 Styling showcase — 10 designs with screenshots and full source
- 📘 API reference — every option, return value, component and type
- 🪝
useDropzoneoptions & return - 🧩 Component / slot catalogue
- 🍳 Recipes — avatar picker, Tailwind, RHF/Zod, chunked uploads, S3
- ❓ FAQ & troubleshooting
- 🔀 Migrating from
react-dropzone - 📝 Changelog
Accessibility
The root is a focusable role="button"; Enter/Space open
the file dialog, the file <input> is visually hidden but reachable by assistive
tech, drag state is exposed for screen-reader-friendly messaging, and every
provided button is a real, labellable <button>. Bring your own aria-label
and aria-describedby (both are forwarded).
SSR / React Server Components
The package ships with the "use client" directive and guards every browser API
(window, URL.createObjectURL, document) so it renders safely on the server
and hydrates without mismatches. Import it inside a Client Component.
Browser support
All evergreen browsers. Directory drops use the widely-supported
webkitGetAsEntry API and degrade gracefully to flat file lists elsewhere.
Contributing
Contributions are welcome! See CONTRIBUTING.md. Run the dev
playground with npm run dev.
License
MIT © Zammad Nasir
