Lunia DS

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.

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-membrane

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
acceptstringundefinedLike an input accept: MIME types, wildcards and extensions, for example "image/*,.pdf".
maxSizenumberundefinedLargest file in bytes.
maxFilesnumberundefinedLargest number of files.
multiplebooleantruefalse keeps one file; a new drop replaces it.
titlestring"Drag files here"Label at rest.
hintstringundefinedLine under the label, for example "PNG, JPG or PDF, up to 10 MB"; also used in the refuse message.
disabledbooleanfalseTurns the drop area off.
EventDescription
uploadCalled per accepted file with (file, { progress(fraction), signal }). Resolve when done, throw to show Failed with Retry, stop when the signal aborts.
filesChangeFires 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
DM1files dragged anywhere over the page (Armed)membranestroke colorborder-primary → brand at 55%duration-md (260)ease-outSwaps at once
DM2files dragged anywhere over the page (Armed)membranefill colorbg-secondary at 45% → brand at 3%duration-xl (380)ease-outSwaps at once
DM3files dragged anywhere over the page (Armed)membranepath (whole line swells out 1.5px, spring)rest shape → 1.5px outduration-4xl (520)ease-throwStays still
DM4a file comes within 200px of the area (Reaching)membranepath (nearest stretch bends out toward the file, up to about 22px)rest shape → bulge toward the fileduration-4xl (520)ease-throwStays still
DM5a file moves outside the areaarrowtransform rotate + translate (turns to face the file, leans up to 4px toward it)pointing up → pointing at the fileduration-md (260)ease-outStays still
DM6the file crosses into the area, or is within 16px (Over)membranepath (the stretched line snaps back with a wobble, then swells 3px)bulge → 3px outduration-6xl (900)ease-throwStays still
DM7Overmembrane + icon circlestroke, fill, circle border and icon colorarmed colors → border-brand, brand at 7%, fg-brand-primaryduration-md (260)ease-outSwaps at once
DM8Overarrowtransform rotate (points down into the area)toward the file → 180degduration-md (260)ease-outStays still
DM9drag state changes (Armed, Reaching, Over, Refusing, notice)labeltext morph ("Drop files here", "Drop to add", "Drop to add 3 files", refuse message)old label → new labelduration-3xl (460)ease-outSwaps at once
DM10the dragged type cannot be added (Refusing)membranepath (the line pulls back from the file and shrinks 2px)rest shape → pulled induration-4xl (520)ease-throwStays still
DM11Refusingmembrane, icon circle, labelstroke, fill and text colorarmed colors → border-error, error at 5%, text-error-primaryduration-lg (320)ease-outSwaps at once
DM12Refusing, a notice, or back to resticon circle icons (arrow, X, check)opacity + filter blur, transform scale1, 0, 1 → 0, blur-swap, scale-swapduration-xl (380)ease-outSwaps at once
DM13drop acceptedmembranepath (whole border pulls in, ripple rings out from the drop point)rest shape → rest shape after the rippleduration-6xl (900)ease-throwStays still
DM14drop refusedmembranepath (pushed out where the file landed, then settles)rest shape → rest shape after the pushduration-6xl (900)ease-throwStays still
DM15drop accepted, or added by browse or pasteadded check (in the icon circle)stroke-dashoffset (draw)1 → 0duration-2xl (420)ease-outdelay 120msShows at once
DM16drop acceptedfile chipopacity + scale (appears at the drop point)0, 0.9 → 1, 1duration-xs (160)ease-outdelay stagger-step x fileShows at once
DM17drop acceptedfile chiptransform translate (flies to its row, dragging the border along until it slips off)drop point → row iconduration-5xl (640)ease-glidedelay stagger-step x fileMoves at once
DM18file addedfile rowgrid-template-rows (fold open)0fr → 1frduration-2xl (420)ease-outResizes at once
DM19file addedfile rowopacity0 → 1duration-lg (320)ease-outShows at once
DM20chip lands on its rowfile chipopacity, background-color, box-shadow ring1, bg-primary, border-primary → 0, transparent, transparentduration-sm (200)ease-outHides at once
DM21chip lands on its rowrow name, size, status and progress lineopacity0 → 1duration-sm (200)ease-outShows at once
DM22upload starts or endspercent columngrid-template-columns + opacity0fr, 0 → 1fr, 1duration-lg (320)ease-outResizes at once
DM23upload progress changespercentnumber rollold percent → new percentduration-5xl (640)ease-outSwaps at once
DM24upload progress changesrow line (progress) + status ringtransform scaleX, stroke-dashoffsetold progress → new progressduration-4xl (520)ease-outResizes at once
DM25status changes (Uploading -> Done or Failed, retry)status mark iconsopacity + filter blur, transform scale1, 0, 1 → 0, blur-swap, scale-swapduration-xl (380)ease-outSwaps at once
DM26upload done (Done)status checkstroke-dashoffset (draw)1 → 0duration-2xl (420)ease-outdelay 120msShows at once
DM27upload done (Done)row line (progress)opacity1 → 0duration-5xl (640)ease-outdelay 300msHides at once
DM28upload fails (Failed)row line + size textline full width in border-error; size text morphs to "Failed"brand line, file size → error line, "Failed"duration-3xl (460)ease-outSwaps at once
DM29remove or cancel a filefile rowgrid-template-rows + opacity (fold shut)1fr, 1 → 0fr, 0duration-2xl (420)ease-outHides 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).