UIForms and Inputsmotion/react

FileDropzone

A drag-and-drop upload area that lifts and glows in the accent color while files hover over it, with a file list that springs new entries in, animates simulated upload progress, draws a check on completion and slides removed files out. Oversized files are rejected inline.

Preview

Drag & drop files here

PNG, JPG, PDF or ZIP up to 10 MB

  • brand-guidelines.pdf2.4 MB
  • hero-shot@2x.png18%

Customize

10 props
6
10
2.4

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add file-dropzone

Writes components/ui/file-dropzone.tsx and components/styles/file-dropzone.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { FileDropzone } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <FileDropzone />6  );7}

Props

PropTypeDefault
title

Main prompt inside the drop area.

string'Drag & drop files here'
hint

Hint line under the prompt; also describes the browse button.

string'PNG, JPG, PDF or ZIP up to 10 MB'
buttonLabel

Label of the browse button.

string'Browse files'
accept

Value of the file input's accept attribute (e.g. 'image/*,.pdf').

string''
multiple

Allow several files; when false a new file replaces the list.

booleantrue
maxFiles

Maximum number of entries kept in the list (oldest are dropped).

number6
maxSizeMB

Files larger than this are listed with an error instead of uploading.

number10
uploadDuration

Approximate simulated upload time per file in seconds.

number2.4
showSampleFiles

Start with two sample entries (one complete, one uploading).

booleantrue
accentColor

Color of the drag-over state, file badges and progress bars.

string'#6366f1'
disabled

Disable dropping and browsing.

booleanfalse
onFilesChange

Called with the accepted real files whenever the list changes.

(files: File[]) => voidundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

The drop area contains a real <button> (described by the hint text) that opens the native file picker, so it works by keyboard and screen reader without drag and drop; the hidden file input is removed from the tab order. Each file shows a role="progressbar" with aria-valuenow and a labelled Remove button. A polite role="status" live region announces added, uploaded and removed files. Under prefers-reduced-motion entries fade without movement and the zone does not scale.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/file-dropzone.tsx
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/file-dropzone.module.css";6 7export interface DropzoneFile {8  /** Stable id */9  id: string;10  /** File name */11  name: string;12  /** Size in bytes */13  size: number;14  /** Upload progress 0-1 */15  progress: number;16  /** Upload state */17  status: "uploading" | "done" | "error";18  /** Error message when status is "error" */19  error?: string;20  /** The underlying File (absent for sample entries) */21  file?: File;22}23 24export interface FileDropzoneProps {25  /** Main prompt inside the drop area */26  title?: string;27  /** Hint line under the prompt (accepted types, limits) */28  hint?: string;29  /** Label of the browse button */30  buttonLabel?: string;31  /** Value for the file input's accept attribute */32  accept?: string;33  /** Allow selecting several files at once */34  multiple?: boolean;35  /** Maximum number of files kept in the list */36  maxFiles?: number;37  /** Maximum file size in megabytes (larger files are rejected) */38  maxSizeMB?: number;39  /** Simulated upload duration in seconds */40  uploadDuration?: number;41  /** Pre-fill the list with two sample files so the demo is not empty */42  showSampleFiles?: boolean;43  /** Accent color for the hover state and progress bars */44  accentColor?: string;45  /** Disable the dropzone */46  disabled?: boolean;47  /** Called whenever the list of real files changes */48  onFilesChange?: (files: File[]) => void;49  /** Optional className merged onto the root */50  className?: string;51  /** Optional inline styles merged onto the root */52  style?: React.CSSProperties;53}54 55const SAMPLES: DropzoneFile[] = [56  { id: "sample-1", name: "brand-guidelines.pdf", size: 2_480_000, progress: 1, status: "done" },57  { id: "sample-2", name: "hero-shot@2x.png", size: 4_120_000, progress: 0.18, status: "uploading" },58];59 60function formatBytes(n: number) {61  if (n < 1024) return `${n} B`;62  if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;63  return `${(n / 1024 / 1024).toFixed(1)} MB`;64}65 66/** Deterministic 0-1 hash so each file gets its own upload pace. */67function seeded(s: string) {68  let h = 2166136261;69  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);70  return ((h >>> 0) % 1000) / 1000;71}72 73export function FileDropzone({74  title = "Drag & drop files here",75  hint = "PNG, JPG, PDF or ZIP up to 10 MB",76  buttonLabel = "Browse files",77  accept = "",78  multiple = true,79  maxFiles = 6,80  maxSizeMB = 10,81  uploadDuration = 2.4,82  showSampleFiles = true,83  accentColor = "#6366f1",84  disabled = false,85  onFilesChange,86  className = "",87  style,88}: FileDropzoneProps) {89  const reduce = useReducedMotion();90  const id = React.useId();91  const [items, setItems] = React.useState<DropzoneFile[]>(() => (showSampleFiles ? SAMPLES : []));92  const [dragging, setDragging] = React.useState(false);93  const [announce, setAnnounce] = React.useState("");94  const inputRef = React.useRef<HTMLInputElement>(null);95  const depth = React.useRef(0);96  const counter = React.useRef(0);97  const uploading = items.some((f) => f.status === "uploading");98 99  // Simulated upload: advance progress on a single rAF loop while anything is uploading.100  React.useEffect(() => {101    if (!uploading) return;102    let raf = 0;103    let last = performance.now();104    const tick = (now: number) => {105      const dt = Math.min(0.1, (now - last) / 1000);106      last = now;107      setItems((list) =>108        list.map((f) => {109          if (f.status !== "uploading") return f;110          const pace = 0.7 + seeded(f.id) * 0.6;111          const p = Math.min(1, f.progress + (dt / Math.max(0.2, uploadDuration)) * pace);112          if (p >= 1) setAnnounce(`${f.name} uploaded`);113          return { ...f, progress: p, status: p >= 1 ? "done" : "uploading" };114        }),115      );116      raf = requestAnimationFrame(tick);117    };118    raf = requestAnimationFrame(tick);119    return () => cancelAnimationFrame(raf);120  }, [uploading, uploadDuration]);121 122  const report = (list: DropzoneFile[]) => onFilesChange?.(list.flatMap((f) => (f.file && f.status !== "error" ? [f.file] : [])));123 124  const addFiles = (incoming: FileList | null) => {125    if (!incoming || disabled) return;126    const picked = Array.from(incoming).slice(0, multiple ? undefined : 1);127    const added = picked.map<DropzoneFile>((file) => {128      counter.current += 1;129      const tooBig = file.size > maxSizeMB * 1024 * 1024;130      return {131        id: `${id}-${counter.current}-${file.name}`,132        name: file.name,133        size: file.size,134        progress: 0,135        status: tooBig ? "error" : "uploading",136        error: tooBig ? `Larger than ${maxSizeMB} MB` : undefined,137        file,138      };139    });140    const next = (multiple ? [...items, ...added] : added).slice(-Math.max(1, maxFiles));141    setItems(next);142    report(next);143    setAnnounce(`${added.length} file${added.length === 1 ? "" : "s"} added`);144  };145 146  const remove = (f: DropzoneFile) => {147    const next = items.filter((x) => x.id !== f.id);148    setItems(next);149    report(next);150    setAnnounce(`${f.name} removed`);151  };152 153  const onDrag = (e: React.DragEvent<HTMLDivElement>, kind: "enter" | "over" | "leave" | "drop") => {154    e.preventDefault();155    if (disabled) return;156    if (kind === "enter") depth.current += 1;157    if (kind === "leave") depth.current = Math.max(0, depth.current - 1);158    if (kind === "drop") {159      depth.current = 0;160      addFiles(e.dataTransfer.files);161    }162    setDragging(depth.current > 0);163  };164 165  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 32 };166 167  return (168    <div className={`${styles.root} ${className}`.trim()} style={{ "--mf-fd-accent": accentColor, ...style } as React.CSSProperties}>169      <motion.div170        className={styles.zone}171        data-dragging={dragging ? "" : undefined}172        data-disabled={disabled ? "" : undefined}173        animate={{ scale: dragging && !reduce ? 1.015 : 1 }}174        transition={spring}175        onDragEnter={(e) => onDrag(e, "enter")}176        onDragOver={(e) => onDrag(e, "over")}177        onDragLeave={(e) => onDrag(e, "leave")}178        onDrop={(e) => onDrag(e, "drop")}179        onClick={(e) => {180          if (e.target === e.currentTarget) inputRef.current?.click();181        }}182      >183        <motion.span className={styles.iconWrap} aria-hidden="true" animate={reduce ? undefined : { y: dragging ? -6 : 0 }} transition={spring}>184          <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">185            <path d="M12 16V4M7 9l5-5 5 5" />186            <path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />187          </svg>188        </motion.span>189        <p className={styles.title}>{dragging ? "Release to upload" : title}</p>190        <p id={`${id}-hint`} className={styles.hint}>191          {hint}192        </p>193        <button type="button" className={styles.browse} disabled={disabled} aria-describedby={`${id}-hint`} onClick={() => inputRef.current?.click()}>194          {buttonLabel}195        </button>196        <input197          ref={inputRef}198          type="file"199          tabIndex={-1}200          aria-hidden="true"201          className={styles.input}202          accept={accept || undefined}203          multiple={multiple}204          disabled={disabled}205          onChange={(e) => {206            addFiles(e.target.files);207            e.target.value = "";208          }}209        />210      </motion.div>211      <ul className={styles.list} aria-label="Files">212        <AnimatePresence initial={false}>213          {items.map((f) => (214            <motion.li215              key={f.id}216              layout={!reduce}217              className={styles.item}218              data-status={f.status}219              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}220              animate={{ opacity: 1, y: 0, scale: 1 }}221              exit={reduce ? { opacity: 0 } : { opacity: 0, x: 24, transition: { duration: 0.18 } }}222              transition={spring}223            >224              <span className={styles.fileIcon} aria-hidden="true">225                {f.name.split(".").pop()?.slice(0, 4).toUpperCase()}226              </span>227              <span className={styles.meta}>228                <span className={styles.nameRow}>229                  <span className={styles.name}>{f.name}</span>230                  <span className={styles.size}>231                    {f.status === "error" ? f.error : f.status === "done" ? formatBytes(f.size) : `${Math.round(f.progress * 100)}%`}232                  </span>233                </span>234                <span235                  className={styles.bar}236                  role="progressbar"237                  aria-label={`Upload progress for ${f.name}`}238                  aria-valuemin={0}239                  aria-valuemax={100}240                  aria-valuenow={Math.round(f.progress * 100)}241                >242                  <span className={styles.fill} style={{ transform: `scaleX(${f.status === "error" ? 1 : f.progress})` }} />243                </span>244              </span>245              <button type="button" className={styles.remove} aria-label={`Remove ${f.name}`} onClick={() => remove(f)}>246                {f.status === "done" ? (247                  <svg aria-hidden="true" className={styles.done} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">248                    <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3 }} />249                  </svg>250                ) : null}251                <svg aria-hidden="true" className={styles.x} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">252                  <path d="M18 6 6 18M6 6l12 12" />253                </svg>254              </button>255            </motion.li>256          ))}257        </AnimatePresence>258      </ul>259      <span className={styles.srOnly} role="status" aria-live="polite">260        {announce}261      </span>262    </div>263  );264}