Inputs
File drop
A drop zone over a real file input that lists chosen files with size, progress and a remove button.
- Built on
<input type="file">DataTransferDrag and drop<progress>:has()- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.3 kB
Brand assets
- brand-kit.zip4.2 MB
Code
import { FileDrop } from "@/components/equip/file-drop";
export default function Demo() {
return (
<div style={{ width: 224 }}>
<FileDrop
label="Brand assets"
accept=".zip,.pdf"
hint="ZIP or PDF, up to 50 MB"
maxSize={50_000_000}
defaultFiles={[{ name: "brand-kit.zip", size: 4_200_000 }]}
/>
</div>
);
}Examples
Types, size and count
Dropped files skip the browser's accept filter, so the part checks type and size itself and says which files it left out.
Receipts
Code
import { FileDrop } from "@/components/equip/file-drop";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 320 }}>
<FileDrop
label="Receipts"
name="receipts"
accept=".pdf,.png,.jpg"
multiple
maxFiles={3}
maxSize={10_000_000}
hint="PDF, PNG or JPG, up to 10 MB each, 3 files"
/>
</div>
);
}Upload progress
Progress is keyed by file name; the part shows it but does no uploading of its own.
Quarterly report
- q3-board-deck-final.pdf18 MB
- revenue-by-region-2026.xlsx912 KB
Code
import { FileDrop } from "@/components/equip/file-drop";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 320 }}>
<FileDrop
label="Quarterly report"
multiple
hint="Any file, up to 200 MB"
defaultFiles={[
{ name: "q3-board-deck-final.pdf", size: 18_400_000 },
{ name: "revenue-by-region-2026.xlsx", size: 912_000 },
]}
progress={{ "q3-board-deck-final.pdf": 0.64, "revenue-by-region-2026.xlsx": 1 }}
/>
</div>
);
}Disabled
Attachments
Code
import { FileDrop } from "@/components/equip/file-drop";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 320 }}>
<FileDrop label="Attachments" disabled hint="Uploads are paused while the workspace is archived." />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label. Names the file input. |
accept | string | none | Same format as the native attribute, e.g. ".pdf,image/*". Also checked for dropped files. |
multiple | boolean | false | Allows more than one file. New files add to the list. |
maxSize | number | none | Largest accepted file, in bytes. |
maxFiles | number | none | Most files at once when multiple is set. |
onFilesChange | (files: File[]) => void | none | Called with the accepted files after every add or remove. |
progress | Record<string, number> | none | Upload progress by file name, 0 to 1. A file with an entry shows a bar. |
hint | ReactNode | none | Shown in the zone and read as its description, e.g. types and sizes. |
disabled | boolean | false | Disables browsing, dropping and removing. |
name | string | none | Form field name of the file input. |
defaultFiles | { name: string; size: number }[] | none | Display-only rows shown before anything is chosen, such as files already uploaded. |
...rest | Omit<ComponentProps<"div">, "onChange"> | none | Every native div attribute, applied to the root. |
Anatomy
| root | .eq-file-drop | Carries data-dragging while files are held over the zone. |
|---|---|---|
| label | .eq-file-drop-label | |
| zone | .eq-file-drop-zone | A label around the file input, so a click anywhere opens the picker. |
| input | .eq-file-drop-input | The native file input, visually hidden but focusable. |
| icon | .eq-file-drop-icon | |
| text | .eq-file-drop-text | |
| cta | .eq-file-drop-cta | |
| browse | .eq-file-drop-browse | |
| hint | .eq-file-drop-hint | |
| error | .eq-file-drop-error | A polite live region naming each file left out and why. |
| list | .eq-file-drop-list | |
| file | .eq-file-drop-file | |
| file icon | .eq-file-drop-file-icon | |
| name | .eq-file-drop-name | Truncate, so the extension stays in view. |
| size | .eq-file-drop-size | |
| remove | .eq-file-drop-remove | A square extra small quiet Button named like Remove brand-kit.zip. |
| progress | .eq-file-drop-progress |
Keyboard
| Tab | Moves to the zone, then to each remove button. |
|---|---|
| EnterSpace | On the zone, opens the file picker. On a remove button, removes that file. |
Notes
- Dropping is the pointer shortcut; the keyboard way to add files is the same picker a click opens.
- The input's own file list is rewritten after every drop and removal, so a plain form post sends exactly the files shown.
- Dropped files skip the browser's accept filter, so type and size are checked again and refused files are named with the reason.
- Removing a file moves focus back to the zone.
- Writing the file list back uses the DataTransfer constructor; an engine without it keeps the files from the last pick, so a form could send a removed file.
- Rows from defaultFiles are display only; they are not in the input and are not submitted.
- The part does not upload anything. Send the files from onFilesChange or a form, and pass progress back by file name.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/file-drop.tsx263 lines
"use client";
import { useId, useRef, useState } from "react";
import type { ComponentProps, DragEvent, ReactNode } from "react";
import { Button } from "./button";
import { Truncate } from "./truncate";
import "./file-drop.css";
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
function formatSize(bytes: number): string {
if (bytes < 1000) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let n = bytes / 1000;
let unit = 0;
while (n >= 1000 && unit < units.length - 1) {
n /= 1000;
unit += 1;
}
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[unit]}`;
}
/** The same rules as the accept attribute: extensions, exact types and type/* wildcards. */
function matches(file: File, accept: string | undefined): boolean {
if (!accept) return true;
const fileName = file.name.toLowerCase();
const type = file.type.toLowerCase();
return accept
.split(",")
.map((rule) => rule.trim().toLowerCase())
.filter(Boolean)
.some((rule) =>
rule.startsWith(".") ? fileName.endsWith(rule) : rule.endsWith("/*") ? type.startsWith(rule.slice(0, -1)) : type === rule,
);
}
type Listed = { name: string; size: number };
type Refusal = { name: string; reason: string };
export type FileDropProps = Omit<ComponentProps<"div">, "onChange"> & {
label: ReactNode;
/** Same format as the native attribute, e.g. ".pdf,image/*". Also checked for dropped files. */
accept?: string;
multiple?: boolean;
/** Largest accepted file, in bytes. */
maxSize?: number;
/** Most files at once when multiple is set. */
maxFiles?: number;
onFilesChange?: (files: File[]) => void;
/** Upload progress by file name, 0 to 1. A file with an entry shows a bar. */
progress?: Record<string, number>;
/** Shown in the zone and read as its description, e.g. the accepted types and sizes. */
hint?: ReactNode;
disabled?: boolean;
name?: string;
/** Rows to show before anything is chosen, such as files already on the server. Display only. */
defaultFiles?: Listed[];
};
export function FileDrop({
label,
accept,
multiple = false,
maxSize,
maxFiles,
onFilesChange,
progress,
hint,
disabled = false,
name,
defaultFiles,
className,
...rest
}: FileDropProps) {
const id = useId();
const input = useRef<HTMLInputElement>(null);
const depth = useRef(0);
const [files, setFiles] = useState<File[]>([]);
const [listed, setListed] = useState<Listed[]>(defaultFiles ?? []);
const [refused, setRefused] = useState<Refusal[]>([]);
const [dragging, setDragging] = useState(false);
const limit = multiple ? (maxFiles ?? Infinity) : 1;
const labelId = `${id}-label`;
const hintId = present(hint) ? `${id}-hint` : undefined;
const errorId = `${id}-error`;
// The input's own file list is rewritten on every change, so a form submits what is shown.
const sync = (next: File[]) => {
setFiles(next);
if (input.current) {
const transfer = new DataTransfer();
for (const file of next) transfer.items.add(file);
input.current.files = transfer.files;
}
onFilesChange?.(next);
};
const add = (incoming: File[]) => {
const refusals: Refusal[] = [];
const ok: File[] = [];
for (const file of incoming) {
if (!matches(file, accept)) refusals.push({ name: file.name, reason: "type not accepted" });
else if (maxSize !== undefined && file.size > maxSize) refusals.push({ name: file.name, reason: `over ${formatSize(maxSize)}` });
else ok.push(file);
}
let next = files;
if (multiple) {
next = files.filter((f) => !ok.some((o) => o.name === f.name));
for (const file of ok) {
if (listed.length + next.length < limit) next.push(file);
else refusals.push({ name: file.name, reason: `only ${limit} files allowed` });
}
} else if (ok.length > 0) {
next = [ok[0]];
setListed([]);
for (const file of ok.slice(1)) refusals.push({ name: file.name, reason: "only one file allowed" });
}
setRefused(refusals);
sync(next);
};
const afterRemove = () => {
setRefused([]);
input.current?.focus();
};
const onDragEnter = (event: DragEvent<HTMLLabelElement>) => {
if (disabled || !event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
depth.current += 1;
setDragging(true);
};
const onDragOver = (event: DragEvent<HTMLLabelElement>) => {
if (disabled || !event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
};
// Entering a child fires leave on the parent first, so a depth count decides when it is really gone.
const onDragLeave = () => {
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
};
const onDrop = (event: DragEvent<HTMLLabelElement>) => {
depth.current = 0;
setDragging(false);
if (disabled) return;
event.preventDefault();
add(Array.from(event.dataTransfer.files));
};
const rows = [
...listed.map((f) => ({
key: `listed-${f.name}`,
name: f.name,
size: f.size,
remove: () => {
setListed((all) => all.filter((x) => x !== f));
afterRemove();
},
})),
...files.map((f, i) => ({
key: `file-${i}-${f.name}`,
name: f.name,
size: f.size,
remove: () => {
sync(files.filter((x) => x !== f));
afterRemove();
},
})),
];
return (
<div
{...rest}
className={["eq-file-drop", className].filter(Boolean).join(" ")}
data-dragging={dragging || undefined}
data-disabled={disabled || undefined}
>
<span className="eq-file-drop-label" id={labelId}>
{label}
</span>
<label className="eq-file-drop-zone" onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<input
ref={input}
type="file"
className="eq-file-drop-input"
name={name}
accept={accept}
multiple={multiple}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={[hintId, refused.length > 0 ? errorId : undefined].filter(Boolean).join(" ") || undefined}
onChange={(event) => add(Array.from(event.target.files ?? []))}
// Space opens the picker natively everywhere; Enter does not in every engine.
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.currentTarget.click();
}}
/>
<svg className="eq-file-drop-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10 13V3.75M6.25 7.5 10 3.75l3.75 3.75M3.75 12.5v2.25c0 .83.67 1.5 1.5 1.5h9.5c.83 0 1.5-.67 1.5-1.5V12.5" />
</svg>
<span className="eq-file-drop-text">
<span className="eq-file-drop-cta">
Drop {multiple ? "files" : "a file"} or <span className="eq-file-drop-browse">browse</span>
</span>
{present(hint) && (
<span className="eq-file-drop-hint" id={hintId}>
{hint}
</span>
)}
</span>
</label>
<p className="eq-file-drop-error" id={errorId} aria-live="polite">
{refused.length > 0 && `Not added: ${refused.map((r) => `${r.name} (${r.reason})`).join(", ")}.`}
</p>
{rows.length > 0 && (
<ul className="eq-file-drop-list" aria-label="Chosen files">
{rows.map((row) => {
const value = progress?.[row.name];
return (
<li key={row.key} className="eq-file-drop-file">
<svg className="eq-file-drop-file-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9.25 1.75H4.5c-.69 0-1.25.56-1.25 1.25v10c0 .69.56 1.25 1.25 1.25h7c.69 0 1.25-.56 1.25-1.25V5.25l-3.5-3.5Z" />
<path d="M9.25 1.75v3.5h3.5" />
</svg>
<Truncate className="eq-file-drop-name" keep={8}>
{row.name}
</Truncate>
<span className="eq-file-drop-size">{formatSize(row.size)}</span>
<Button
variant="quiet"
size="xs"
square
className="eq-file-drop-remove"
aria-label={`Remove ${row.name}`}
disabled={disabled}
onClick={row.remove}
lead={
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="m4.5 4.5 7 7m0-7-7 7" />
</svg>
}
/>
{value !== undefined && (
<progress className="eq-file-drop-progress" max={1} value={Math.max(0, Math.min(1, value))} aria-label={`Uploading ${row.name}`} />
)}
</li>
);
})}
</ul>
)}
</div>
);
}components/equip/file-drop.css183 lines
@layer equip {
.eq-file-drop {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-file-drop-label {
width: fit-content;
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-file-drop-zone {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--eq-space-3);
min-height: var(--eq-h-l);
padding: var(--eq-space-2) var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
/* A file held over the zone lifts it: dashed ink edge, tinted floor. */
.eq-file-drop[data-dragging] .eq-file-drop-zone {
border-style: dashed;
border-color: var(--eq-ink);
background: color-mix(in srgb, var(--eq-raised), var(--eq-ink) 6%);
scale: 1.02;
}
@media (hover: hover) {
.eq-file-drop:not([data-disabled]) .eq-file-drop-zone:hover {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
background: color-mix(in srgb, var(--eq-raised), var(--eq-ink) 3%);
}
}
.eq-file-drop:not([data-disabled], [data-dragging]) .eq-file-drop-zone:active {
scale: var(--eq-press);
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-file-drop-zone:has(.eq-file-drop-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-file-drop[data-disabled] .eq-file-drop-zone {
opacity: 0.45;
cursor: not-allowed;
}
/* The real file input: focusable and announced, drawn by the zone around it. */
.eq-file-drop-input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
opacity: 0;
}
.eq-file-drop-icon {
flex: none;
width: 20px;
height: 20px;
color: var(--eq-ink-2);
}
.eq-file-drop-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.eq-file-drop-cta {
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-file-drop-browse {
font-weight: var(--eq-weight-strong);
text-decoration: underline;
text-underline-offset: 2px;
}
.eq-file-drop-hint {
font: var(--eq-weight) var(--eq-text-xs) / 1.35 var(--eq-font);
color: var(--eq-ink-2);
}
.eq-file-drop-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
color: var(--eq-danger);
}
/* Empty, the live region stays in the page but takes no room and no gap. */
.eq-file-drop-error:empty {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.eq-file-drop-list {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
margin: var(--eq-space-1) 0 0;
padding: 0;
list-style: none;
}
.eq-file-drop-file {
box-sizing: border-box;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
column-gap: var(--eq-space-2);
min-height: var(--eq-h-s);
padding: 1px 1px 1px var(--eq-space-2);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-s);
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-file-drop-file-icon {
width: 16px;
height: 16px;
color: var(--eq-ink-3);
}
.eq-file-drop-name {
min-width: 0;
}
.eq-file-drop-size {
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
color: var(--eq-ink-3);
white-space: nowrap;
}
.eq-file-drop-progress {
grid-column: 1 / -1;
display: block;
width: calc(100% - var(--eq-space-2));
height: 2px;
margin: 0 0 var(--eq-space-1);
border: 0;
border-radius: var(--eq-radius-full);
overflow: hidden;
appearance: none;
background: var(--eq-tint-strong);
}
.eq-file-drop-progress::-webkit-progress-bar {
background: transparent;
}
.eq-file-drop-progress::-webkit-progress-value {
background: var(--eq-solid);
transition: width var(--eq-dur-move) var(--eq-ease);
}
.eq-file-drop-progress::-moz-progress-bar {
background: var(--eq-solid);
}
}