Lunia DS

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.

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

Usage

The demo above, as code.

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

PropTypeDefaultDescription
acceptstringundefinedNative input syntax: MIME types, wildcards or extensions, for example "image/*,.pdf".
maxSizenumberundefinedLargest size of each file in bytes.
maxFilesnumberundefinedLargest number of files.
multiplebooleantruefalse keeps one file; a new valid file replaces it.
titlestring"Drag files here"Title in the drop area.
hintstringbuilt from accept, maxSize and maxFilesLine under the title, for example "PNG, JPG, PDF. Up to 10 MB per file. 5 files at most."
disabledbooleanfalseTurns the drop area, paste and the row actions off.
EventDescription
uploadCalled 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).
filesChangeFires with the current files after each add, replace or remove.
filesRejectedFires 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
DZ1drag files over the area (Drag over)drop areaborder-color + background-colorborder-primary, bg-secondary → border-brand, utility-brand-50duration-sm (200)ease-outSwaps at once
DZ2drag files over the area (Drag over)upload icon circleborder-color + colorborder-primary, fg-quaternary → utility-brand-300, fg-brand-primaryduration-sm (200)ease-outSwaps at once
DZ3drag enters or leaves the areatitletext morph"Drag files here" → "Drop to add"duration-3xl (460)ease-outSwaps at once
DZ4upload progress changesstatus mark progress ringstroke-dashoffset1 - old progress → 1 - new progressduration-sm (200)ease-exitResizes at once
DZ5upload progress changesprogress line (row bottom edge)transform scaleX (from the left)old progress → new progressduration-sm (200)ease-outResizes at once
DZ6upload progress changespercentnumber rollold percent → new percentduration-xl (380)ease-outSwaps at once
DZ7status changes (Uploading -> Done or Failed, Failed -> Uploading on retry)status mark icons (ring, check, error icon)opacity + filter blur1, 0 → 0, blur-swapduration-sm (200)ease-outSwaps at once
DZ8status changesstatus mark iconstransform scale1 → scale-swapduration-xl (380)ease-outSwaps at once
DZ9upload done (Done)checkstroke-dashoffset (draw)1 → 0duration-md (260)ease-exitShows at once
DZ10upload fails (Failed)size texttext morph to "Failed" in text-error-primaryfile size → "Failed"duration-3xl (460)ease-outSwaps at once
DZ11hover a row action or the dismiss buttonretry, remove, dismiss buttonsbackground-color, colortransparent, fg-quaternary → bg-secondary, fg-primaryduration-xs (160)ease-outSwaps 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.