File Upload
A dropzone over a real file input, plus the upload queue nobody ships.
Any file, up to 10 MB
Installation
pnpm dlx @dowel-ui/cli add file-uploadNo npm packages are needed beyond what Dowel already requires.
Accessibility
The APG has no dropzone pattern, and inventing one is the usual failure — a div with role="button", a keydown handler, and a picker keyboard users never reach. The control here is a real input[type=file] with a real label, which is already operable, already announced, and opens the picker on Enter and Space with nothing added. Drag and drop is a pointer convenience layered on top; every drop can also be done from the input. Each uploading file gets a progressbar named after it, status is always stated in words as well as drawn, and one polite live region summarises the whole queue rather than six per-file regions talking over each other. The optional dropzone icon is aria-hidden. With animateExit, a removed row lingers only as an aria-hidden, inert copy with no buttons while it animates out.
Props
FileUpload
| Prop | Type | Default |
|---|---|---|
label (required)Names the control. | string | — |
onFiles (required) | (files: File[]) => void | — |
accept | string | — |
disabled | boolean | false |
hintShown under the prompt: accepted types, size limit. | ReactNode | — |
iconA decorative icon above the prompt, hidden from assistive technology. It lifts while a drag is over the dropzone. | ReactNode | — |
multiple | boolean | true |
children | ReactNode | — |
Plus every attribute of <div> except onDrop.
FileUploadList
| Prop | Type | Default |
|---|---|---|
files (required) | QueuedFile[] | — |
animateExitLets a removed file slide out before it leaves the DOM. The departing row is | boolean | false |
onCancel | (id: string) => void | — |
onRemove | (id: string) => void | — |
onRetry | (id: string) => void | — |
Plus every attribute of <ul>.
FileUploadItem
| Prop | Type | Default |
|---|---|---|
entry (required) | QueuedFile | — |
onCancel | (id: string) => void | — |
onRemove | (id: string) => void | — |
onRetry | (id: string) => void | — |
Plus every attribute of <li> except children.
FileUploadStatus
| Prop | Type | Default |
|---|---|---|
stats (required) | { total: number; active: number; done: number; failed: number } | — |
Plus every attribute of <p>.
Quality
9/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — fails
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Source
This is exactly what dowel add file-upload writes into your project, with imports rewritten to your own path alias.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
* The upload queue.
*
* The dropzone is the most duplicated component in the React ecosystem and the
* least valuable half: it is a styled rectangle over `<input type="file">`.
* What almost nobody ships is this — progress, cancel, retry with backoff, and
* a concurrency limit — so every team writes it again, usually twice, because
* the first version has no cancel and no retry.
*
* Transport is injected. This never calls `fetch` or constructs a request,
* because the request is the part that differs everywhere: presigned S3 PUT,
* multipart POST, tus, an internal gateway with its own auth. `upload` receives
* the file, a progress callback and an AbortSignal, and returns a promise. That
* is the whole contract.
*
* Progress needs XHR, not fetch. `fetch` still cannot report upload progress in
* any shipping browser — there is no readable stream for the request body — so
* a transport that wants a real progress bar has to use XMLHttpRequest. That is
* the consumer's choice to make, and `xhrUpload` below is a working example
* rather than a dependency.
*/
export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "cancelled";
export interface QueuedFile {
/** Stable across retries, so React keys and announcements do not jump. */
id: string;
file: File;
status: UploadStatus;
/** 0–1, or null when the transport cannot report it. */
progress: number | null;
/** Why it failed or was refused. Kept so it can be read and acted on. */
error?: string;
attempts: number;
}
export interface UploadContext {
onProgress: (fraction: number) => void;
signal: AbortSignal;
}
export type UploadFn = (file: File, context: UploadContext) => Promise<void>;
export interface UploadQueueOptions {
upload: UploadFn;
/** Uploads running at once. More is not faster once the link is saturated. */
concurrency?: number;
/** Automatic retries per file before it is left failed. */
maxAttempts?: number;
/** Largest accepted file, in bytes. */
maxSize?: number;
/** Accepted types, as an `accept` attribute value: ".pdf,image/*". */
accept?: string;
/** Total files the queue will hold. */
maxFiles?: number;
onComplete?: (file: QueuedFile) => void;
}
/** Backoff between attempts. Bounded, because a person is waiting. */
function backoffMs(attempt: number): number {
return Math.min(8000, 500 * 2 ** (attempt - 1));
}
/** Matches a file against an `accept` string the same way the browser does. */
export function matchesAccept(file: File, accept: string | undefined): boolean {
if (!accept) return true;
const patterns = accept
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean);
if (patterns.length === 0) return true;
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
return patterns.some((pattern) => {
if (pattern.startsWith(".")) return name.endsWith(pattern);
if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
return type === pattern;
});
}
export function formatBytes(bytes: number): string {
if (bytes < 1000) return `${String(bytes)} B`;
const units = ["kB", "MB", "GB", "TB"];
let value = bytes / 1000;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit] ?? "B"}`;
}
let sequence = 0;
export function useUploadQueue(options: UploadQueueOptions) {
const {
upload,
concurrency = 3,
maxAttempts = 3,
maxSize,
accept,
maxFiles,
onComplete,
} = options;
const [files, setFiles] = useState<QueuedFile[]>([]);
const controllers = useRef(new Map<string, AbortController>());
const running = useRef(new Set<string>());
// The live option values, so an in-flight upload reads the current transport
// without `run` being re-created — which would churn the scheduling effect
// every time a consumer passes an inline `upload`. Written after commit
// rather than during render, because a ref write during render is unsafe
// under concurrent rendering; `run` only reads this once it is executing,
// which is always after the effect has run.
const latest = useRef({ upload, concurrency, maxAttempts, onComplete });
useEffect(() => {
latest.current = { upload, concurrency, maxAttempts, onComplete };
});
const patch = useCallback((id: string, changes: Partial<QueuedFile>) => {
setFiles((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)),
);
}, []);
const run = useCallback(
async (entry: QueuedFile) => {
const controller = new AbortController();
controllers.current.set(entry.id, controller);
const attempt = entry.attempts + 1;
patch(entry.id, {
status: "uploading",
progress: 0,
attempts: attempt,
error: undefined,
});
try {
await latest.current.upload(entry.file, {
signal: controller.signal,
onProgress: (fraction) => {
patch(entry.id, { progress: Math.min(1, Math.max(0, fraction)) });
},
});
patch(entry.id, { status: "done", progress: 1 });
latest.current.onComplete?.({ ...entry, status: "done", progress: 1 });
} catch (error) {
if (controller.signal.aborted) {
patch(entry.id, { status: "cancelled", progress: null });
} else if (attempt < latest.current.maxAttempts) {
// Back off, then requeue. Releasing the slot only after the delay is
// what makes the wait real rather than a busy retry, and the file
// re-enters the queue like any other so retries obey concurrency too.
setTimeout(() => {
running.current.delete(entry.id);
controllers.current.delete(entry.id);
patch(entry.id, { status: "queued", progress: null });
}, backoffMs(attempt));
return;
} else {
patch(entry.id, {
status: "failed",
progress: null,
error: error instanceof Error ? error.message : "Upload failed",
});
}
}
controllers.current.delete(entry.id);
running.current.delete(entry.id);
// No explicit pump: the scheduling effect below reacts to the state
// change and starts whatever can start next.
setFiles((current) => [...current]);
},
[patch],
);
// Starts whatever can start, once the state that made it startable has been
// committed. Doing this inside a setState updater — the obvious shortcut —
// means React may run it twice and upload the same file twice; the `running`
// set guards that, but the effect is the honest place for it.
useEffect(() => {
const free = concurrency - running.current.size;
if (free <= 0) return;
const next = files
.filter((entry) => entry.status === "queued" && !running.current.has(entry.id))
.slice(0, free);
for (const entry of next) {
running.current.add(entry.id);
void run(entry);
}
}, [files, concurrency, run]);
/** Validates and enqueues. A rejected file is kept and told why. */
const add = useCallback(
(incoming: File[]) => {
setFiles((current) => {
const room = maxFiles === undefined ? incoming.length : maxFiles - current.length;
const admitted: QueuedFile[] = [];
for (const file of incoming.slice(0, Math.max(0, room))) {
sequence += 1;
const id = `upload-${String(sequence)}`;
// A rejected file becomes a failed entry rather than disappearing.
// Silently dropping a file the reader chose is how these components
// lose work without anybody noticing.
if (maxSize !== undefined && file.size > maxSize) {
admitted.push({
id,
file,
status: "failed",
progress: null,
attempts: 0,
error: `Larger than ${formatBytes(maxSize)}`,
});
continue;
}
if (!matchesAccept(file, accept)) {
admitted.push({
id,
file,
status: "failed",
progress: null,
attempts: 0,
error: "Type not accepted",
});
continue;
}
admitted.push({ id, file, status: "queued", progress: null, attempts: 0 });
}
return [...current, ...admitted];
});
},
[accept, maxFiles, maxSize],
);
const cancel = useCallback((id: string) => {
controllers.current.get(id)?.abort();
}, []);
const retry = useCallback((id: string) => {
setFiles((current) =>
current.map((entry) =>
entry.id === id
? { ...entry, status: "queued" as const, error: undefined, attempts: 0 }
: entry,
),
);
}, []);
const remove = useCallback((id: string) => {
controllers.current.get(id)?.abort();
controllers.current.delete(id);
running.current.delete(id);
setFiles((current) => current.filter((entry) => entry.id !== id));
}, []);
const clearCompleted = useCallback(() => {
setFiles((current) => current.filter((entry) => entry.status !== "done"));
}, []);
const stats = useMemo(() => {
const by = (status: UploadStatus) =>
files.filter((entry) => entry.status === status).length;
return {
total: files.length,
queued: by("queued"),
uploading: by("uploading"),
done: by("done"),
failed: by("failed"),
cancelled: by("cancelled"),
active: by("queued") + by("uploading"),
};
}, [files]);
return { files, stats, add, cancel, retry, remove, clearCompleted };
}
/**
* A working XHR transport, as an example rather than a dependency.
*
* XMLHttpRequest and not fetch, because fetch still cannot report upload
* progress: there is no readable stream for a request body in any shipping
* browser. Copy this and change the request to match your backend.
*/
export function xhrUpload(
url: string,
init: { method?: string; headers?: Record<string, string> } = {},
): UploadFn {
return (file, { onProgress, signal }) =>
new Promise<void>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open(init.method ?? "POST", url);
for (const [header, value] of Object.entries(init.headers ?? {})) {
request.setRequestHeader(header, value);
}
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total);
});
request.addEventListener("load", () => {
if (request.status >= 200 && request.status < 300) resolve();
else reject(new Error(`Upload failed with status ${String(request.status)}`));
});
request.addEventListener("error", () => {
reject(new Error("Network error"));
});
request.addEventListener("abort", () => {
reject(new Error("Cancelled"));
});
signal.addEventListener("abort", () => {
request.abort();
});
const body = new FormData();
body.append("file", file);
request.send(body);
});
}
"use client";
// Motion from SmoothUI AnimatedFileUpload (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithRef,
type DragEvent,
type ReactNode,
} from "react";
import { disabledStyles, focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";
import { formatBytes, type QueuedFile, type UploadStatus } from "./upload-queue";
/**
* The visible half of uploading. The queue is in `upload-queue.ts` and is the
* part worth owning.
*
* There is no dropzone pattern in the WAI-ARIA APG, and inventing one is the
* usual failure: a `div` with `role="button"`, a keydown handler, and a file
* picker that keyboard users can never reach. So the control here is a real
* `<input type="file">` with a real `<label>`. That is already operable by
* keyboard, already announces itself, already opens the picker on Enter and
* Space, and needs nothing added. Drag and drop is layered on top as a pointer
* convenience, and every drop can also be done from the input.
*/
/*
* Motion (SmoothUI's AnimatedFileUpload): the dropzone swells slightly while a
* drag is over it and an optional icon lifts; queued files slide in from the
* inline start, and with `animateExit` slide out toward the inline end.
* Keyframes ship with the component (ADR 0014) on duration tokens, so reduced
* motion collapses them; the travel direction follows :dir().
*/
const ITEM_KEYFRAMES = `
@keyframes dowel-file-upload-in{from{opacity:0;transform:translateX(var(--dowel-file-upload-x)) scale(0.95)}}
@keyframes dowel-file-upload-out{to{opacity:0;transform:translateX(calc(-1.5 * var(--dowel-file-upload-x))) scale(0.95)}}
[data-slot="file-upload-item"]{--dowel-file-upload-x:-1rem;animation:dowel-file-upload-in var(--duration-normal) var(--ease-out-quint)}
[data-slot="file-upload-item"]:dir(rtl){--dowel-file-upload-x:1rem}
[data-slot="file-upload-item"][data-state="closed"]{animation:dowel-file-upload-out var(--duration-fast) var(--ease-in-quint) forwards}
`;
/** Clears departing rows whose animation never runs (hidden, unstyled). */
const EXIT_FALLBACK_MS = 1000;
const STATUS_LABEL: Record<UploadStatus, string> = {
queued: "Waiting",
uploading: "Uploading",
done: "Uploaded",
failed: "Failed",
cancelled: "Cancelled",
};
export interface FileUploadProps extends Omit<ComponentPropsWithRef<"div">, "onDrop"> {
/** Names the control. */
label: string;
onFiles: (files: File[]) => void;
accept?: string;
multiple?: boolean;
disabled?: boolean;
/** Shown under the prompt: accepted types, size limit. */
hint?: ReactNode;
/**
* A decorative icon above the prompt, hidden from assistive technology. It
* lifts while a drag is over the dropzone.
*/
icon?: ReactNode;
children?: ReactNode;
}
export function FileUpload({
className,
label,
onFiles,
accept,
multiple = true,
disabled = false,
hint,
icon,
children,
...props
}: FileUploadProps) {
const inputId = useId();
const hintId = useId();
const [dragging, setDragging] = useState(false);
const depth = useRef(0);
function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
depth.current = 0;
setDragging(false);
if (disabled) return;
const dropped = [...event.dataTransfer.files];
if (dropped.length > 0) onFiles(multiple ? dropped : dropped.slice(0, 1));
}
return (
<div data-slot="file-upload" className={cn("flex flex-col gap-3", className)} {...props}>
{/* dragenter/dragleave fire for every child element, so a plain boolean
flickers as the pointer crosses the prompt text. Counting depth is
what makes the highlight steady. */}
<div
data-slot="file-upload-dropzone"
data-dragging={dragging || undefined}
onDragEnter={(event) => {
event.preventDefault();
depth.current += 1;
if (!disabled) setDragging(true);
}}
onDragLeave={() => {
depth.current -= 1;
if (depth.current <= 0) setDragging(false);
}}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={handleDrop}
className={cn(
"rounded-lg border border-dashed border-border-strong bg-muted/30 px-4 py-6 text-center",
"transition-[color,background-color,border-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
dragging && "scale-[1.02] border-primary bg-primary/5",
disabled && "pointer-events-none opacity-55",
)}
>
{icon ? (
<div
aria-hidden="true"
data-slot="file-upload-icon"
className={cn(
"mx-auto mb-2 flex w-fit text-muted-foreground [&_svg:not([class*='size-'])]:size-8",
"transition-[color,translate,scale] duration-[var(--duration-normal)] ease-[var(--ease-overshoot)]",
dragging && "-translate-y-1 scale-115 text-foreground",
)}
>
{icon}
</div>
) : null}
{/* The label is the control. Clicking it opens the picker, Enter and
Space activate it, and assistive technology already describes it. */}
<label
htmlFor={inputId}
className={cn(
"inline-flex cursor-pointer flex-col items-center gap-1 rounded-md px-2 py-1 text-sm",
"focus-within:ring-2 focus-within:ring-ring/55",
)}
>
<span className="font-medium">{label}</span>
<span className="text-xs text-muted-foreground">
Drop {multiple ? "files" : "a file"} here, or choose from your device
</span>
<input
id={inputId}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
aria-describedby={hint ? hintId : undefined}
onChange={(event) => {
const chosen = [...(event.target.files ?? [])];
if (chosen.length > 0) onFiles(chosen);
// Reset, so choosing the same file twice fires change twice.
event.target.value = "";
}}
className="sr-only"
/>
</label>
{hint ? (
<p id={hintId} className="mt-2 text-xs text-muted-foreground">
{hint}
</p>
) : null}
</div>
{children}
</div>
);
}
export interface FileUploadListProps extends ComponentPropsWithRef<"ul"> {
files: QueuedFile[];
/**
* Lets a removed file slide out before it leaves the DOM. The departing row
* is `aria-hidden`, inert and has no buttons. Off by default, because the old
* row briefly remains in the markup.
*/
animateExit?: boolean;
onCancel?: (id: string) => void;
onRetry?: (id: string) => void;
onRemove?: (id: string) => void;
}
interface LeavingEntry {
entry: QueuedFile;
index: number;
}
export function FileUploadList({
className,
files,
onCancel,
onRetry,
onRemove,
animateExit = false,
...props
}: FileUploadListProps) {
// Derived from the previous render rather than in an effect, so a departing
// row is in place on the same commit its live one leaves.
const [previous, setPrevious] = useState(files);
const [leaving, setLeaving] = useState<LeavingEntry[]>([]);
if (previous !== files) {
setPrevious(files);
const present = new Set(files.map((entry) => entry.id));
const gone = animateExit
? previous.flatMap((entry, index) => (present.has(entry.id) ? [] : [{ entry, index }]))
: [];
setLeaving((current) => [
...current.filter(
(item) =>
!present.has(item.entry.id) && !gone.some((g) => g.entry.id === item.entry.id),
),
...gone,
]);
}
useEffect(() => {
if (leaving.length === 0) return;
const timer = setTimeout(() => {
setLeaving([]);
}, EXIT_FALLBACK_MS);
return () => {
clearTimeout(timer);
};
}, [leaving]);
// A native listener: React maps onAnimationEnd to a vendor-prefixed name
// wherever AnimationEvent is missing. React 19 runs the returned cleanup.
const listenForExitEnd = useCallback(
(id: string) => (element: HTMLLIElement | null) => {
if (!element) return;
const onEnd = (event: Event) => {
if (event.target !== element) return;
setLeaving((current) => current.filter((item) => item.entry.id !== id));
};
element.addEventListener("animationend", onEnd);
return () => {
element.removeEventListener("animationend", onEnd);
};
},
[],
);
const rows: { entry: QueuedFile; gone: boolean }[] = files.map((entry) => ({
entry,
gone: false,
}));
for (const item of [...leaving].sort((a, b) => a.index - b.index)) {
rows.splice(Math.min(item.index, rows.length), 0, { entry: item.entry, gone: true });
}
if (rows.length === 0) return null;
return (
<ul
data-slot="file-upload-list"
className={cn("flex list-none flex-col gap-2", className)}
{...props}
>
{rows.map(({ entry, gone }) =>
gone ? (
<FileUploadItem
key={`gone:${entry.id}`}
ref={listenForExitEnd(entry.id)}
entry={entry}
data-state="closed"
aria-hidden="true"
inert
/>
) : (
<FileUploadItem
key={entry.id}
entry={entry}
onCancel={onCancel}
onRetry={onRetry}
onRemove={onRemove}
/>
),
)}
</ul>
);
}
export interface FileUploadItemProps extends Omit<ComponentPropsWithRef<"li">, "children"> {
entry: QueuedFile;
onCancel?: (id: string) => void;
onRetry?: (id: string) => void;
onRemove?: (id: string) => void;
}
export function FileUploadItem({
className,
entry,
onCancel,
onRetry,
onRemove,
...props
}: FileUploadItemProps) {
const { id, file, status, progress, error } = entry;
const percent = progress === null ? null : Math.round(progress * 100);
return (
<li
data-slot="file-upload-item"
data-status={status}
className={cn(
"flex items-center gap-3 rounded-lg border px-3 py-2 text-sm",
status === "failed"
? "border-destructive/40 bg-destructive/5"
: "border-border bg-card",
className,
)}
{...props}
>
<style href="dowel-file-upload" precedence="dowel">
{ITEM_KEYFRAMES}
</style>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-medium">{file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatBytes(file.size)}
</span>
</div>
{/* Status in words, always. A bar at 60% with a red tint does not say
whether it is uploading, stalled or failed. */}
<p
className={cn(
"text-xs",
status === "failed" ? "text-destructive" : "text-muted-foreground",
)}
>
{STATUS_LABEL[status]}
{status === "uploading" && percent !== null ? ` · ${String(percent)}%` : null}
{error ? ` · ${error}` : null}
</p>
{status === "uploading" ? (
<div
role="progressbar"
aria-label={`Uploading ${file.name}`}
aria-valuenow={percent ?? undefined}
aria-valuemin={0}
aria-valuemax={100}
className="h-1 w-full overflow-hidden rounded-full bg-muted"
>
<div
data-slot="file-upload-progress"
className="h-full rounded-full bg-primary transition-[width] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]"
style={{ width: `${String(percent ?? 0)}%` }}
/>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{status === "uploading" && onCancel ? (
<ItemButton onClick={() => onCancel(id)}>Cancel</ItemButton>
) : null}
{(status === "failed" || status === "cancelled") && onRetry ? (
<ItemButton onClick={() => onRetry(id)}>Retry</ItemButton>
) : null}
{onRemove ? (
<ItemButton onClick={() => onRemove(id)} aria-label={`Remove ${file.name}`}>
Remove
</ItemButton>
) : null}
</div>
</li>
);
}
function ItemButton({ className, ...props }: ComponentPropsWithRef<"button">) {
return (
<button
type="button"
className={cn(
"rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
"transition-colors hover:bg-accent hover:text-accent-foreground",
focusRing,
disabledStyles,
className,
)}
{...props}
/>
);
}
/**
* One sentence covering the whole queue, announced politely.
*
* Per-file live regions would talk over each other the moment two uploads run
* at once; one summary of the set is readable where six competing ones are not.
*/
export function FileUploadStatus({
className,
stats,
...props
}: ComponentPropsWithRef<"p"> & {
stats: { total: number; active: number; done: number; failed: number };
}) {
const { total, active, done, failed } = stats;
// Progress through the set, not a count of what happens to be in flight.
// "3 of 5 uploading" is wrong the moment a concurrency limit holds two back,
// and it never tells the reader how much of the job is left.
const message =
total === 0
? ""
: active > 0
? `${String(done)} of ${String(total)} uploaded`
: failed > 0
? `${String(done)} uploaded, ${String(failed)} failed`
: `${String(done)} uploaded`;
return (
<p
data-slot="file-upload-status"
aria-live="polite"
className={cn("text-xs text-muted-foreground", className)}
{...props}
>
{message}
</p>
);
}