Overview
State indication without glow — idle, dragging, and error morph the border and scale. Click opens the file picker. Reduced motion keeps opacity-only feedback.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/file-dropzone.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useRef, useState } from "react";4import { motion, useReducedMotion } from "motion/react";5import { Upload, FileWarning } from "lucide-react";6import { cn } from "@/lib/utils";7 8export type FileDropzoneProps = {9 onFiles?: (files: File[]) => void;10 accept?: string;11 multiple?: boolean;12 disabled?: boolean;13 className?: string;14 label?: string;15 hint?: string;16};17 18type ZoneState = "idle" | "dragging" | "error";19 20const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };21 22function readMs(name: string, fb: number) {23 const v = parseFloat(24 getComputedStyle(document.documentElement).getPropertyValue(name),25 );26 return Number.isFinite(v) ? v : fb;27}28 29export function FileDropzone({30 onFiles,31 accept,32 multiple = true,33 disabled,34 className,35 label = "Drop files here",36 hint = "or click to browse",37}: FileDropzoneProps) {38 const reduce = useReducedMotion();39 const inputRef = useRef<HTMLInputElement>(null);40 const wrapRef = useRef<HTMLDivElement>(null);41 const zoneRef = useRef<HTMLDivElement>(null);42 const textRef = useRef<HTMLSpanElement>(null);43 const [state, setState] = useState<ZoneState>("idle");44 const [shaking, setShaking] = useState(false);45 const dragDepth = useRef(0);46 const revertTimer = useRef<number | null>(null);47 const swapTimer = useRef<number | null>(null);48 const prevState = useRef<ZoneState>("idle");49 50 const labelFor = useCallback(51 (s: ZoneState) =>52 s === "error"53 ? "File type not accepted"54 : s === "dragging"55 ? "Release to upload"56 : label,57 [label],58 );59 60 // Text states swap (04) — three-phase on idle / dragging / error61 useEffect(() => {62 const el = textRef.current;63 if (!el) return;64 const next = labelFor(state);65 if (prevState.current === state && el.textContent === next) return;66 const changed = prevState.current !== state;67 prevState.current = state;68 69 if (!changed || reduce) {70 el.textContent = next;71 el.classList.remove("is-exit", "is-enter-start");72 return;73 }74 75 if (swapTimer.current) window.clearTimeout(swapTimer.current);76 const dur = readMs("--text-swap-dur", 150);77 el.classList.add("is-exit");78 swapTimer.current = window.setTimeout(() => {79 el.textContent = next;80 el.classList.remove("is-exit");81 el.classList.add("is-enter-start");82 void el.offsetHeight;83 el.classList.remove("is-enter-start");84 swapTimer.current = null;85 }, dur);86 87 return () => {88 if (swapTimer.current) window.clearTimeout(swapTimer.current);89 };90 }, [state, labelFor, reduce]);91 92 // Keep label in sync if the idle prop changes while idle93 useEffect(() => {94 if (state !== "idle") return;95 const el = textRef.current;96 if (el && el.textContent !== label) el.textContent = label;97 }, [label, state]);98 99 const clearErrorClasses = useCallback(() => {100 if (revertTimer.current) {101 window.clearTimeout(revertTimer.current);102 revertTimer.current = null;103 }104 setShaking(false);105 wrapRef.current?.classList.remove("is-error");106 zoneRef.current?.classList.remove("is-error", "is-shaking");107 }, []);108 109 const showError = useCallback(() => {110 setState("error");111 112 if (reduce) {113 clearErrorClasses();114 window.setTimeout(() => setState("idle"), 1600);115 return;116 }117 118 const wrap = wrapRef.current;119 const zone = zoneRef.current;120 if (!wrap || !zone) {121 window.setTimeout(() => setState("idle"), 1600);122 return;123 }124 125 wrap.classList.add("is-error");126 zone.classList.add("is-error");127 128 // Orthogonal shake replay (12): remove → reflow → re-add129 zone.classList.remove("is-shaking");130 void zone.offsetWidth;131 zone.classList.add("is-shaking");132 setShaking(true);133 134 const shakeMs =135 readMs("--shake-dur-a", 80) * 2 + readMs("--shake-dur-b", 60) * 2;136 window.setTimeout(() => {137 zone.classList.remove("is-shaking");138 setShaking(false);139 }, shakeMs + 20);140 141 if (revertTimer.current) window.clearTimeout(revertTimer.current);142 const hold = readMs("--revert-hold", 3000);143 revertTimer.current = window.setTimeout(() => {144 revertTimer.current = null;145 wrap.classList.remove("is-error");146 zone.classList.remove("is-error");147 setState("idle");148 }, shakeMs + hold);149 }, [clearErrorClasses, reduce]);150 151 const emit = useCallback(152 (list: FileList | null) => {153 if (!list?.length || disabled) return;154 const files = Array.from(list);155 if (accept) {156 const ok = files.every((f) => {157 const types = accept.split(",").map((s) => s.trim());158 return types.some((t) => {159 if (t.startsWith(".")) return f.name.toLowerCase().endsWith(t);160 if (t.endsWith("/*"))161 return f.type.startsWith(t.replace("/*", "/"));162 return f.type === t;163 });164 });165 if (!ok) {166 showError();167 return;168 }169 }170 clearErrorClasses();171 onFiles?.(files);172 setState("idle");173 },174 [accept, clearErrorClasses, disabled, onFiles, showError],175 );176 177 return (178 <div ref={wrapRef} className={cn("t-input-wrap w-full", className)}>179 <motion.div180 ref={zoneRef}181 role="button"182 tabIndex={disabled ? -1 : 0}183 aria-disabled={disabled}184 aria-label={label}185 onClick={() => {186 if (!disabled) inputRef.current?.click();187 }}188 onKeyDown={(e) => {189 if (e.key === "Enter" || e.key === " ") {190 e.preventDefault();191 if (!disabled) inputRef.current?.click();192 }193 }}194 onDragEnter={(e) => {195 e.preventDefault();196 if (disabled) return;197 clearErrorClasses();198 dragDepth.current += 1;199 setState("dragging");200 }}201 onDragOver={(e) => e.preventDefault()}202 onDragLeave={(e) => {203 e.preventDefault();204 dragDepth.current = Math.max(0, dragDepth.current - 1);205 if (dragDepth.current === 0 && state !== "error") setState("idle");206 }}207 onDrop={(e) => {208 e.preventDefault();209 dragDepth.current = 0;210 emit(e.dataTransfer.files);211 }}212 whileTap={disabled || reduce ? undefined : { scale: 0.96 }}213 animate={214 reduce215 ? { opacity: state === "dragging" ? 0.85 : 1, scale: 1 }216 : {217 scale: state === "dragging" ? 1.02 : 1,218 opacity: 1,219 }220 }221 transition={reduce ? { duration: 0.1 } : SPRING}222 className={cn(223 "t-input relative flex min-h-[10rem] w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-[var(--radius-lg)] px-6 py-8 text-center outline-none",224 "border-2 border-transparent",225 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",226 state === "dragging"227 ? "bg-[var(--color-paper-2)] ring-2 ring-[var(--color-ink)]"228 : state === "error"229 ? "is-error border-red-600/50 bg-[var(--color-paper)] ring-2 ring-[var(--color-ink-muted)]"230 : "bg-[var(--color-paper)] ring-1 ring-dashed ring-[var(--color-rule)]",231 shaking && "is-shaking",232 disabled && "cursor-not-allowed opacity-50",233 )}234 >235 <input236 ref={inputRef}237 type="file"238 className="sr-only"239 accept={accept}240 multiple={multiple}241 disabled={disabled}242 onChange={(e) => {243 emit(e.target.files);244 e.target.value = "";245 }}246 />247 <div className="flex flex-col items-center gap-2">248 <span249 className="t-icon-swap inline-grid size-8 place-items-center"250 data-state={state === "error" ? "b" : "a"}251 aria-hidden252 >253 <span className="t-icon" data-icon="a">254 <Upload255 className="size-8 text-[var(--color-ink)]"256 strokeWidth={1.5}257 />258 </span>259 <span className="t-icon" data-icon="b">260 <FileWarning261 className="size-8 text-[var(--color-ink-muted)]"262 strokeWidth={1.5}263 />264 </span>265 </span>266 <span267 ref={textRef}268 className="t-text-swap text-sm font-medium text-[var(--color-ink)]"269 >270 {label}271 </span>272 {state === "idle" ? (273 <span className="text-xs text-[var(--color-ink-muted)]">{hint}</span>274 ) : null}275 </div>276 </motion.div>277 <p278 className="t-error-msg m-0 mt-1.5 text-center text-xs text-red-600 dark:text-red-400"279 role="alert"280 >281 File type not accepted282 </p>283 </div>284 );285}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { FileDropzone } from "@/components/ui/file-dropzone";2 3<FileDropzone4 accept=".png,.jpg,.pdf"5 onFiles={(files) => upload(files)}6/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| onFiles | (files: File[]) => void | No | — | Fires with accepted files. |
| accept | string | No | — | MIME / extension filter (same as input accept). |
| multiple | boolean | No | true | Allow multiple files. |
| disabled | boolean | No | — | Disables drop and click. |
| label | string | No | — | Primary idle label. |
Best Practices
- Always pair with
acceptwhen the backend is strict. - Handle upload progress elsewhere — this surface is the affordance only.
- Error state is brief; don’t leave the zone stuck in error.