File Upload
Dropzone that also opens the native file picker, with a list below it that pops each file in and collapses smoothly on remove.
"use client";
import { useState } from "react";
import { FileUpload } from "@/components/motion/file-upload";
export function FileUploadPreview() {
const [files, setFiles] = useState<File[]>([]);
return (
<div className="w-full max-w-sm">
<FileUpload
value={files}
onChange={setFiles}
accept="image/png,image/jpeg,.pdf"
maxSize={5 * 1024 * 1024}
/>
</div>
);
}
"use client";
// easeui.dev/components/motion/file-upload
import { File as FileIcon, Upload, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type DragEvent, useId, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface FileUploadProps {
/** Controlled list of picked files. */
value?: File[];
/** Starting files when uncontrolled. Default []. */
defaultValue?: File[];
onChange?: (files: File[]) => void;
/** MIME types or extensions accepted, passed to the native file input. */
accept?: string;
/** Allows picking or dropping more than one file at once. Default true. */
multiple?: boolean;
/** Largest a single file may be, in bytes. Larger files are rejected with an inline error. */
maxSize?: number;
disabled?: boolean;
className?: string;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* A dropzone that also opens the native file picker on click. Picked files
* land in a list below it, popping in on arrival, collapsing on remove.
* Nothing here uploads anything: it just hands back File objects.
*/
export function FileUpload({
value,
defaultValue = [],
onChange,
accept,
multiple = true,
maxSize,
disabled = false,
className,
}: FileUploadProps) {
const reduce = useReducedMotion();
const inputId = useId();
const dragCount = useRef(0);
const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const isControlled = value !== undefined;
const files = isControlled ? value : uncontrolled;
const setFiles = (next: File[]) => {
if (!isControlled) setUncontrolled(next);
onChange?.(next);
};
const showError = (message: string) => {
setError(message);
if (errorTimer.current) clearTimeout(errorTimer.current);
errorTimer.current = setTimeout(() => setError(null), 3000);
};
const addFiles = (incoming: File[]) => {
const accepted: File[] = [];
let oversized: File | undefined;
for (const file of incoming) {
if (maxSize && file.size > maxSize) {
oversized = file;
continue;
}
accepted.push(file);
}
if (oversized) showError(`${oversized.name} is larger than ${formatSize(maxSize as number)}.`);
if (accepted.length === 0) return;
setFiles(multiple ? [...files, ...accepted] : accepted.slice(0, 1));
};
const onDrop = (event: DragEvent<HTMLLabelElement>) => {
event.preventDefault();
dragCount.current = 0;
setDragging(false);
if (!disabled) addFiles(Array.from(event.dataTransfer.files));
};
const onDragEnter = (event: DragEvent<HTMLLabelElement>) => {
event.preventDefault();
dragCount.current += 1;
if (!disabled) setDragging(true);
};
const onDragLeave = (event: DragEvent<HTMLLabelElement>) => {
event.preventDefault();
dragCount.current = Math.max(0, dragCount.current - 1);
if (dragCount.current === 0) setDragging(false);
};
const remove = (index: number) => setFiles(files.filter((_, i) => i !== index));
return (
<div className={cn("flex flex-col gap-3", className)}>
<label
htmlFor={inputId}
data-dragging={dragging}
onDragEnter={onDragEnter}
onDragOver={(event) => event.preventDefault()}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn(
"flex touch-manipulation flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-border px-6 py-10 text-center transition-colors duration-150 ease-out",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:border-border-strong hover:bg-muted/40",
"data-[dragging=true]:border-accent data-[dragging=true]:bg-accent/5",
)}
>
<Upload aria-hidden="true" className="h-5 w-5 text-muted-foreground" />
<p className="text-sm text-foreground">
<span className="font-medium">Click to upload</span> or drag and drop
</p>
{accept ? <p className="text-xs text-muted-foreground">{accept.split(",").join(", ")}</p> : null}
<input
id={inputId}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
onChange={(event) => {
if (event.target.files) addFiles(Array.from(event.target.files));
event.target.value = "";
}}
className="sr-only"
/>
</label>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
{files.length > 0 ? (
<ul className="flex flex-col gap-1.5">
<AnimatePresence initial={false}>
{files.map((file, index) => (
<motion.li
key={`${file.name}-${file.size}-${file.lastModified}`}
layout={!reduce}
initial={reduce ? false : { opacity: 0, y: 6, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className="flex items-center gap-2.5 overflow-hidden rounded-xl bg-card px-3 py-2 shadow-[0_0_0_1px_var(--border)]"
>
<FileIcon aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatSize(file.size)}</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(index)}
className="relative -mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 after:absolute after:-inset-2 hover:bg-muted hover:text-foreground"
>
<X aria-hidden="true" className="h-3.5 w-3.5" />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
) : null}
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/file-upload
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/file-upload.tsx "use client"; // easeui.dev/components/motion/file-upload import { File as FileIcon, Upload, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type DragEvent, useId, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface FileUploadProps { /** Controlled list of picked files. */ value?: File[]; /** Starting files when uncontrolled. Default []. */ defaultValue?: File[]; onChange?: (files: File[]) => void; /** MIME types or extensions accepted, passed to the native file input. */ accept?: string; /** Allows picking or dropping more than one file at once. Default true. */ multiple?: boolean; /** Largest a single file may be, in bytes. Larger files are rejected with an inline error. */ maxSize?: number; disabled?: boolean; className?: string; } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** * A dropzone that also opens the native file picker on click. Picked files * land in a list below it, popping in on arrival, collapsing on remove. * Nothing here uploads anything: it just hands back File objects. */ export function FileUpload({ value, defaultValue = [], onChange, accept, multiple = true, maxSize, disabled = false, className, }: FileUploadProps) { const reduce = useReducedMotion(); const inputId = useId(); const dragCount = useRef(0); const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const [uncontrolled, setUncontrolled] = useState(defaultValue); const [dragging, setDragging] = useState(false); const [error, setError] = useState<string | null>(null); const isControlled = value !== undefined; const files = isControlled ? value : uncontrolled; const setFiles = (next: File[]) => { if (!isControlled) setUncontrolled(next); onChange?.(next); }; const showError = (message: string) => { setError(message); if (errorTimer.current) clearTimeout(errorTimer.current); errorTimer.current = setTimeout(() => setError(null), 3000); }; const addFiles = (incoming: File[]) => { const accepted: File[] = []; let oversized: File | undefined; for (const file of incoming) { if (maxSize && file.size > maxSize) { oversized = file; continue; } accepted.push(file); } if (oversized) showError(`${oversized.name} is larger than ${formatSize(maxSize as number)}.`); if (accepted.length === 0) return; setFiles(multiple ? [...files, ...accepted] : accepted.slice(0, 1)); }; const onDrop = (event: DragEvent<HTMLLabelElement>) => { event.preventDefault(); dragCount.current = 0; setDragging(false); if (!disabled) addFiles(Array.from(event.dataTransfer.files)); }; const onDragEnter = (event: DragEvent<HTMLLabelElement>) => { event.preventDefault(); dragCount.current += 1; if (!disabled) setDragging(true); }; const onDragLeave = (event: DragEvent<HTMLLabelElement>) => { event.preventDefault(); dragCount.current = Math.max(0, dragCount.current - 1); if (dragCount.current === 0) setDragging(false); }; const remove = (index: number) => setFiles(files.filter((_, i) => i !== index)); return ( <div className={cn("flex flex-col gap-3", className)}> <label htmlFor={inputId} data-dragging={dragging} onDragEnter={onDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={onDragLeave} onDrop={onDrop} className={cn( "flex touch-manipulation flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-border px-6 py-10 text-center transition-colors duration-150 ease-out", disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:border-border-strong hover:bg-muted/40", "data-[dragging=true]:border-accent data-[dragging=true]:bg-accent/5", )} > <Upload aria-hidden="true" className="h-5 w-5 text-muted-foreground" /> <p className="text-sm text-foreground"> <span className="font-medium">Click to upload</span> or drag and drop </p> {accept ? <p className="text-xs text-muted-foreground">{accept.split(",").join(", ")}</p> : null} <input id={inputId} type="file" accept={accept} multiple={multiple} disabled={disabled} onChange={(event) => { if (event.target.files) addFiles(Array.from(event.target.files)); event.target.value = ""; }} className="sr-only" /> </label> {error ? <p className="text-xs text-destructive">{error}</p> : null} {files.length > 0 ? ( <ul className="flex flex-col gap-1.5"> <AnimatePresence initial={false}> {files.map((file, index) => ( <motion.li key={`${file.name}-${file.size}-${file.lastModified}`} layout={!reduce} initial={reduce ? false : { opacity: 0, y: 6, height: 0 }} animate={{ opacity: 1, y: 0, height: "auto" }} exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }} transition={{ duration: 0.18, ease: EASE_OUT }} className="flex items-center gap-2.5 overflow-hidden rounded-xl bg-card px-3 py-2 shadow-[0_0_0_1px_var(--border)]" > <FileIcon aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" /> <span className="min-w-0 flex-1 truncate text-sm text-foreground">{file.name}</span> <span className="shrink-0 text-xs text-muted-foreground">{formatSize(file.size)}</span> <button type="button" aria-label={`Remove ${file.name}`} onClick={() => remove(index)} className="relative -mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 after:absolute after:-inset-2 hover:bg-muted hover:text-foreground" > <X aria-hidden="true" className="h-3.5 w-3.5" /> </button> </motion.li> ))} </AnimatePresence> </ul> ) : null} </div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value? | {} | - | Controlled list of picked files. |
| defaultValue? | {} | [] | Starting files when uncontrolled. Default []. |
| onChange? | ((files: {}) => void) | - | - |
| accept? | string | - | MIME types or extensions accepted, passed to the native file input. |
| multiple? | boolean | true | Allows picking or dropping more than one file at once. Default true. |
| maxSize? | number | - | Largest a single file may be, in bytes. Larger files are rejected with an inline error. |
| disabled? | boolean | false | - |
| className? | string | - | - |
Updated