Actions and inputs
Drop zone
A file drop area that lights up while files are dragged over it and lists each file with a progress ring that fills while it uploads and turns into a drawn check when it is done.
Live preview
Loading preview
A "Project files" card with accept "image/png,image/jpeg,.pdf" (so the built hint reads "PNG, JPG or PDF", as in Figma), maxSize 10 MB and maxFiles 5. The demo upload reports progress in steps over about 2 seconds; a file named with "fail" fails once so Retry shows. Replay clears the list, drops "brief.pdf" and "moodboard.png" (both upload) and "notes.docx" (rejected for its type).
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add drop-zoneUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { DropZone, type DropZoneHandle, type DropZoneUpload } from './drop-zone';
const MB = 1024 * 1024;
/** A stand-in file: the content is empty, the size is what the row shows. */
function demoFile(name: string, size: number, type: string) {
const file = new File([''], name, { type });
Object.defineProperty(file, 'size', { value: Math.round(size) });
return file;
}
/** Pretend upload in steps over about 2 seconds; a name with "fail" fails once at 64%. */
const demoUpload = (
file: File,
{ progress, signal }: Parameters<DropZoneUpload>[1],
failedOnce: Set<string>,
) =>
new Promise<void>((resolve, reject) => {
const steps = [0.18, 0.42, 0.64, 0.86, 1];
let step = 0;
const timer = window.setInterval(() => {
if (signal.aborted) {
window.clearInterval(timer);
return;
}
const value = steps[step++] ?? 1;
progress(value);
if (value === 0.64 && file.name.includes('fail') && !failedOnce.has(file.name)) {
failedOnce.add(file.name);
window.clearInterval(timer);
reject(new Error('The connection was lost. Try again.'));
} else if (value === 1) {
window.clearInterval(timer);
resolve();
}
}, 400);
});
export default function DropZoneDemo({ replay = 0 }: { replay?: number }) {
const zone = React.useRef<DropZoneHandle>(null);
const failedOnce = React.useRef(new Set<string>());
const upload = React.useCallback<DropZoneUpload>(
(file, context) => demoUpload(file, context, failedOnce.current),
[],
);
React.useEffect(() => {
if (replay === 0) return;
failedOnce.current.clear();
zone.current?.clear();
const timers = [
window.setTimeout(() => zone.current?.setDragOver(true), 300),
window.setTimeout(() => {
zone.current?.setDragOver(false);
zone.current?.addFiles([
demoFile('brief.pdf', 2.4 * MB, 'application/pdf'),
demoFile('moodboard.png', 1.8 * MB, 'image/png'),
demoFile(
'notes.docx',
0.3 * MB,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
),
]);
}, 1300),
];
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div className="flex w-[520px] max-w-full flex-col gap-xl rounded-2xl border border-secondary bg-primary p-2xl shadow-xs">
<h3 className="text-sm font-semibold text-primary">Project files</h3>
<DropZone
ref={zone}
accept="image/png,image/jpeg,.pdf"
maxSize={10 * MB}
maxFiles={5}
onUpload={upload}
/>
</div>
);
}
API
Parts: root, drop area (button), upload icon circle, title (text morph), hint (Browse, paste, accepted types and limits), hidden file input, rejection list (alert, up to 3 lines + count, dismiss button), file list, file row (type icon, name, size or "Failed", percent, status mark, retry button, remove button, error line, progress line), status mark (progress ring, check, error icon), status (live region, screen reader only).
| Prop | Type | Default | Description |
|---|---|---|---|
| accept | string | undefined | Native input syntax: MIME types, wildcards or extensions, for example "image/*,.pdf". |
| maxSize | number | undefined | Largest size of each file in bytes. |
| maxFiles | number | undefined | Largest number of files. |
| multiple | boolean | true | false keeps one file; a new valid file replaces it. |
| title | string | "Drag files here" | Title in the drop area. |
| hint | string | built from accept, maxSize and maxFiles | Line under the title, for example "PNG, JPG, PDF. Up to 10 MB per file. 5 files at most." |
| disabled | boolean | false | Turns the drop area, paste and the row actions off. |
| Event | Description |
|---|---|
| upload | Called per accepted file with (file, { progress(fraction), signal }). Resolve when uploaded, throw to show Failed with Retry, and stop when the signal aborts (remove during upload). |
| filesChange | Fires with the current files after each add, replace or remove. |
| filesRejected | Fires with the rejected files and reasons: { file, code: "type" | "size" | "count" | "duplicate", reason }. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| DZ1 | drag files over the area (Drag over) | drop area | border-color + background-color | border-primary, bg-secondary → border-brand, utility-brand-50 | duration-sm (200)ease-out | Swaps at once |
| DZ2 | drag files over the area (Drag over) | upload icon circle | border-color + color | border-primary, fg-quaternary → utility-brand-300, fg-brand-primary | duration-sm (200)ease-out | Swaps at once |
| DZ3 | drag enters or leaves the area | title | text morph | "Drag files here" → "Drop to add" | duration-3xl (460)ease-out | Swaps at once |
| DZ4 | upload progress changes | status mark progress ring | stroke-dashoffset | 1 - old progress → 1 - new progress | duration-sm (200)ease-exit | Resizes at once |
| DZ5 | upload progress changes | progress line (row bottom edge) | transform scaleX (from the left) | old progress → new progress | duration-sm (200)ease-out | Resizes at once |
| DZ6 | upload progress changes | percent | number roll | old percent → new percent | duration-xl (380)ease-out | Swaps at once |
| DZ7 | status changes (Uploading -> Done or Failed, Failed -> Uploading on retry) | status mark icons (ring, check, error icon) | opacity + filter blur | 1, 0 → 0, blur-swap | duration-sm (200)ease-out | Swaps at once |
| DZ8 | status changes | status mark icons | transform scale | 1 → scale-swap | duration-xl (380)ease-out | Swaps at once |
| DZ9 | upload done (Done) | check | stroke-dashoffset (draw) | 1 → 0 | duration-md (260)ease-exit | Shows at once |
| DZ10 | upload fails (Failed) | size text | text morph to "Failed" in text-error-primary | file size → "Failed" | duration-3xl (460)ease-out | Swaps at once |
| DZ11 | hover a row action or the dismiss button | retry, remove, dismiss buttons | background-color, color | transparent, fg-quaternary → bg-secondary, fg-primary | duration-xs (160)ease-out | Swaps at once |
Accessibility
- The drop area is a native button named "Browse files" ("Browse file" when multiple is false), described by the title and the hint; Enter or Space opens the file picker, so the drop area also works without dragging.
- Paste anywhere in the component adds files from the clipboard.
- Rejected files show in an alert (role alert): "photo.heic: This file type is not allowed.", with "2 more files skipped." after 3 lines, and a dismiss button named "Dismiss file errors".
- The file list is a list named "Files"; each row has a hidden status word (Ready, Uploading, Uploaded, Upload failed), the error line linked with aria-describedby, and a progressbar named "Upload progress for <file>" with aria-valuenow.
- Row buttons are named "Remove <file>", "Cancel upload of <file>" and "Retry upload of <file>"; they are 32px, above the 24px minimum target.
- A polite, atomic live region reads short results: "report.pdf uploaded.", "Removed report.pdf.", "Trying report.pdf again." (the membrane drop zone reads the same lines)
- Reduced motion: colors swap, the ring and line jump to each progress value, icons swap and the check shows at once.