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
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add file-dropzoneWrites 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.
1import { FileDropzone } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <FileDropzone />6 );7}Props
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
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}