Actions and inputs
Drop zone membrane
A file drop area whose border acts like an elastic skin: it bends toward a file you bring close, pulls in and ripples when you drop, pulls back from a file type it cannot take, and each dropped file flies down to its row in the list.
Live preview
Loading preview
An "Attach files" card with accept "image/*,.pdf", hint "PNG, JPG or PDF, up to 10 MB" and maxFiles 5. The demo upload reports progress over about 2 seconds; one file fails once. Replay scripts a drag through the handle: a PDF moves in from the top right (the border reaches for it), crosses in, drops (gulp, the chip flies to its row), then a DOCX drag is refused (the border pulls back, the label turns red).
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add drop-zone-membraneUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { DropZoneIcon } from '../drop-zone/drop-zone-file-row';
import { dropZoneIcons } from '../drop-zone/drop-zone-variants';
import { prefersReducedMotion } from '../lib/motion';
import {
type DraggedFile,
DropZoneMembrane,
type DropZoneMembraneHandle,
type DropZoneUpload,
type Point,
} from './drop-zone-membrane';
const MB = 1024 * 1024;
const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
/** 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 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.22, 0.48, 0.64, 0.88, 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.'));
} else if (value === 1) {
window.clearInterval(timer);
resolve();
}
}, 400);
});
// The scripted drags, in area pixels (the area is 168px tall); the last point of each is the drop.
const PDF_PATH: Point[] = [
{ x: 640, y: -170 },
{ x: 470, y: -70 },
{ x: 410, y: -28 },
{ x: 380, y: 44 },
];
const DOCX_PATH: Point[] = [
{ x: 640, y: -170 },
{ x: 440, y: -64 },
{ x: 400, y: -36 },
];
export default function DropZoneMembraneDemo({ replay = 0 }: { replay?: number }) {
const root = React.useRef<HTMLDivElement>(null);
const zone = React.useRef<DropZoneMembraneHandle>(null);
const failedOnce = React.useRef(new Set<string>());
const [ghost, setGhost] = React.useState<{ point: Point; name: string } | null>(null);
const upload = React.useCallback<DropZoneUpload>(
(file, context) => demoUpload(file, context, failedOnce.current),
[],
);
React.useEffect(() => {
if (replay === 0) return;
let frame = 0;
const timers: number[] = [];
const at = (ms: number, run: () => void) => timers.push(window.setTimeout(run, ms));
// Moves the pretend drag image along a path, easing in and out, and tells the membrane where it is.
// With reduced motion the drag image waits at its start, then jumps to the drop point.
const glide = (path: Point[], ms: number, file: DraggedFile & { name: string }, done: () => void) => {
const reduced = prefersReducedMotion(root.current);
let start = 0;
const tick = (now: number) => {
start ||= now;
const t = reduced ? (now - start >= ms ? 1 : 0) : Math.min(1, (now - start) / ms);
if (reduced && t === 0 && now !== start) {
frame = requestAnimationFrame(tick);
return;
}
const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
const f = eased * (path.length - 1);
const i = Math.min(path.length - 2, Math.floor(f));
const a = path[i] as Point;
const b = path[i + 1] as Point;
const point = { x: a.x + (b.x - a.x) * (f - i), y: a.y + (b.y - a.y) * (f - i) };
setGhost({ point, name: file.name });
zone.current?.dragTo(point, [file]);
if (t < 1) frame = requestAnimationFrame(tick);
else done();
};
frame = requestAnimationFrame(tick);
};
failedOnce.current.clear();
zone.current?.clear();
setGhost(null);
at(300, () =>
glide(PDF_PATH, 1400, { name: 'brief.pdf', type: 'application/pdf' }, () =>
at(250, () => {
setGhost(null);
zone.current?.drop([demoFile('brief.pdf', 2.4 * MB, 'application/pdf')]);
}),
),
);
at(4300, () =>
glide(DOCX_PATH, 1100, { name: 'notes.docx', type: DOCX }, () =>
at(500, () => {
setGhost(null);
zone.current?.drop([demoFile('notes.docx', 0.3 * MB, DOCX)]);
}),
),
);
return () => {
cancelAnimationFrame(frame);
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div
ref={root}
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">Attach files</h3>
<div className="relative">
<DropZoneMembrane
ref={zone}
accept="image/*,.pdf"
maxSize={10 * MB}
maxFiles={5}
hint="PNG, JPG or PDF, up to 10 MB"
onUpload={upload}
/>
{ghost && (
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0 z-20 flex -translate-x-1/2 -translate-y-1/2 items-center gap-md whitespace-nowrap rounded-full border border-primary bg-primary py-sm pr-xl pl-lg text-sm text-primary opacity-90 shadow-md [&_svg]:size-4 [&_svg]:text-fg-quaternary"
style={{ left: ghost.point.x, top: ghost.point.y }}
>
<DropZoneIcon d={dropZoneIcons.file} />
{ghost.name}
</div>
)}
</div>
</div>
);
}
API
Parts: root, drop area (button), membrane (SVG path drawn from a ring of points), icon circle (arrow that turns to the file, refuse X, added check), label (text morph), hint (Browse, paste, accepted types), hidden file input, file chip (flies from the drop point to its row), file list, file row (fold, type icon, name, size or "Failed", percent, status mark, retry, remove, row line as progress), status mark (progress ring, drawn check, error icon), status (live region, screen reader only).
| Prop | Type | Default | Description |
|---|---|---|---|
| accept | string | undefined | Like an input accept: MIME types, wildcards and extensions, for example "image/*,.pdf". |
| maxSize | number | undefined | Largest file in bytes. |
| maxFiles | number | undefined | Largest number of files. |
| multiple | boolean | true | false keeps one file; a new drop replaces it. |
| title | string | "Drag files here" | Label at rest. |
| hint | string | undefined | Line under the label, for example "PNG, JPG or PDF, up to 10 MB"; also used in the refuse message. |
| disabled | boolean | false | Turns the drop area off. |
| Event | Description |
|---|---|
| upload | Called per accepted file with (file, { progress(fraction), signal }). Resolve when done, throw to show Failed with Retry, stop when the signal aborts. |
| filesChange | Fires with the files in the list after every add and remove. |
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 |
|---|---|---|---|---|---|---|
| DM1 | files dragged anywhere over the page (Armed) | membrane | stroke color | border-primary → brand at 55% | duration-md (260)ease-out | Swaps at once |
| DM2 | files dragged anywhere over the page (Armed) | membrane | fill color | bg-secondary at 45% → brand at 3% | duration-xl (380)ease-out | Swaps at once |
| DM3 | files dragged anywhere over the page (Armed) | membrane | path (whole line swells out 1.5px, spring) | rest shape → 1.5px out | duration-4xl (520)ease-throw | Stays still |
| DM4 | a file comes within 200px of the area (Reaching) | membrane | path (nearest stretch bends out toward the file, up to about 22px) | rest shape → bulge toward the file | duration-4xl (520)ease-throw | Stays still |
| DM5 | a file moves outside the area | arrow | transform rotate + translate (turns to face the file, leans up to 4px toward it) | pointing up → pointing at the file | duration-md (260)ease-out | Stays still |
| DM6 | the file crosses into the area, or is within 16px (Over) | membrane | path (the stretched line snaps back with a wobble, then swells 3px) | bulge → 3px out | duration-6xl (900)ease-throw | Stays still |
| DM7 | Over | membrane + icon circle | stroke, fill, circle border and icon color | armed colors → border-brand, brand at 7%, fg-brand-primary | duration-md (260)ease-out | Swaps at once |
| DM8 | Over | arrow | transform rotate (points down into the area) | toward the file → 180deg | duration-md (260)ease-out | Stays still |
| DM9 | drag state changes (Armed, Reaching, Over, Refusing, notice) | label | text morph ("Drop files here", "Drop to add", "Drop to add 3 files", refuse message) | old label → new label | duration-3xl (460)ease-out | Swaps at once |
| DM10 | the dragged type cannot be added (Refusing) | membrane | path (the line pulls back from the file and shrinks 2px) | rest shape → pulled in | duration-4xl (520)ease-throw | Stays still |
| DM11 | Refusing | membrane, icon circle, label | stroke, fill and text color | armed colors → border-error, error at 5%, text-error-primary | duration-lg (320)ease-out | Swaps at once |
| DM12 | Refusing, a notice, or back to rest | icon circle icons (arrow, X, check) | opacity + filter blur, transform scale | 1, 0, 1 → 0, blur-swap, scale-swap | duration-xl (380)ease-out | Swaps at once |
| DM13 | drop accepted | membrane | path (whole border pulls in, ripple rings out from the drop point) | rest shape → rest shape after the ripple | duration-6xl (900)ease-throw | Stays still |
| DM14 | drop refused | membrane | path (pushed out where the file landed, then settles) | rest shape → rest shape after the push | duration-6xl (900)ease-throw | Stays still |
| DM15 | drop accepted, or added by browse or paste | added check (in the icon circle) | stroke-dashoffset (draw) | 1 → 0 | duration-2xl (420)ease-outdelay 120ms | Shows at once |
| DM16 | drop accepted | file chip | opacity + scale (appears at the drop point) | 0, 0.9 → 1, 1 | duration-xs (160)ease-outdelay stagger-step x file | Shows at once |
| DM17 | drop accepted | file chip | transform translate (flies to its row, dragging the border along until it slips off) | drop point → row icon | duration-5xl (640)ease-glidedelay stagger-step x file | Moves at once |
| DM18 | file added | file row | grid-template-rows (fold open) | 0fr → 1fr | duration-2xl (420)ease-out | Resizes at once |
| DM19 | file added | file row | opacity | 0 → 1 | duration-lg (320)ease-out | Shows at once |
| DM20 | chip lands on its row | file chip | opacity, background-color, box-shadow ring | 1, bg-primary, border-primary → 0, transparent, transparent | duration-sm (200)ease-out | Hides at once |
| DM21 | chip lands on its row | row name, size, status and progress line | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| DM22 | upload starts or ends | percent column | grid-template-columns + opacity | 0fr, 0 → 1fr, 1 | duration-lg (320)ease-out | Resizes at once |
| DM23 | upload progress changes | percent | number roll | old percent → new percent | duration-5xl (640)ease-out | Swaps at once |
| DM24 | upload progress changes | row line (progress) + status ring | transform scaleX, stroke-dashoffset | old progress → new progress | duration-4xl (520)ease-out | Resizes at once |
| DM25 | status changes (Uploading -> Done or Failed, retry) | status mark icons | opacity + filter blur, transform scale | 1, 0, 1 → 0, blur-swap, scale-swap | duration-xl (380)ease-out | Swaps at once |
| DM26 | upload done (Done) | status check | stroke-dashoffset (draw) | 1 → 0 | duration-2xl (420)ease-outdelay 120ms | Shows at once |
| DM27 | upload done (Done) | row line (progress) | opacity | 1 → 0 | duration-5xl (640)ease-outdelay 300ms | Hides at once |
| DM28 | upload fails (Failed) | row line + size text | line full width in border-error; size text morphs to "Failed" | brand line, file size → error line, "Failed" | duration-3xl (460)ease-out | Swaps at once |
| DM29 | remove or cancel a file | file row | grid-template-rows + opacity (fold shut) | 1fr, 1 → 0fr, 0 | duration-2xl (420)ease-out | Hides at once |
Accessibility
- The drop area is a native button described by the hint; Enter or Space opens the file picker, and paste works while it has focus, so dragging is never required.
- The membrane, icons and flying chips are aria-hidden; the label is visible text that says what a drop will do or why it is refused.
- The file list is a list named "Files"; row buttons are named "Remove <file>", "Cancel <file>" and "Retry <file>".
- A live region (role status) reads every result: "Added brief.pdf", "Added 3 files. 1 file skipped.", "brief.pdf uploaded", "brief.pdf did not upload. <reason>", and refuse reasons such as "notes.docx is not an allowed type".
- Row buttons are 32px (the shared drop zone file row, Figma 380:5714), above the 24px minimum target; the drop area is at least 168px tall.
- The component also takes drags from inside the app through its handle (drag, cancel, drop, browse), with the same feedback.
- Reduced motion: the border never bends and stays a plain rounded rectangle; colors, labels and icons change at once; no chip flight (rows show at once).