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.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/file-dropzone.tsx
tsx
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";import { motion, useReducedMotion } from "motion/react";import { Upload, FileWarning } from "lucide-react";import { cn } from "@/lib/utils";
export type FileDropzoneProps = {  onFiles?: (files: File[]) => void;  accept?: string;  multiple?: boolean;  disabled?: boolean;  className?: string;  label?: string;  hint?: string;};
type ZoneState = "idle" | "dragging" | "error";
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };
function readMs(name: string, fb: number) {  const v = parseFloat(    getComputedStyle(document.documentElement).getPropertyValue(name),  );  return Number.isFinite(v) ? v : fb;}
export function FileDropzone({  onFiles,  accept,  multiple = true,  disabled,  className,  label = "Drop files here",  hint = "or click to browse",}: FileDropzoneProps) {  const reduce = useReducedMotion();  const inputRef = useRef<HTMLInputElement>(null);  const wrapRef = useRef<HTMLDivElement>(null);  const zoneRef = useRef<HTMLDivElement>(null);  const textRef = useRef<HTMLSpanElement>(null);  const [state, setState] = useState<ZoneState>("idle");  const [shaking, setShaking] = useState(false);  const dragDepth = useRef(0);  const revertTimer = useRef<number | null>(null);  const swapTimer = useRef<number | null>(null);  const prevState = useRef<ZoneState>("idle");
  const labelFor = useCallback(    (s: ZoneState) =>      s === "error"        ? "File type not accepted"        : s === "dragging"          ? "Release to upload"          : label,    [label],  );
  // Text states swap (04) — three-phase on idle / dragging / error  useEffect(() => {    const el = textRef.current;    if (!el) return;    const next = labelFor(state);    if (prevState.current === state && el.textContent === next) return;    const changed = prevState.current !== state;    prevState.current = state;
    if (!changed || reduce) {      el.textContent = next;      el.classList.remove("is-exit", "is-enter-start");      return;    }
    if (swapTimer.current) window.clearTimeout(swapTimer.current);    const dur = readMs("--text-swap-dur", 150);    el.classList.add("is-exit");    swapTimer.current = window.setTimeout(() => {      el.textContent = next;      el.classList.remove("is-exit");      el.classList.add("is-enter-start");      void el.offsetHeight;      el.classList.remove("is-enter-start");      swapTimer.current = null;    }, dur);
    return () => {      if (swapTimer.current) window.clearTimeout(swapTimer.current);    };  }, [state, labelFor, reduce]);
  // Keep label in sync if the idle prop changes while idle  useEffect(() => {    if (state !== "idle") return;    const el = textRef.current;    if (el && el.textContent !== label) el.textContent = label;  }, [label, state]);
  const clearErrorClasses = useCallback(() => {    if (revertTimer.current) {      window.clearTimeout(revertTimer.current);      revertTimer.current = null;    }    setShaking(false);    wrapRef.current?.classList.remove("is-error");    zoneRef.current?.classList.remove("is-error", "is-shaking");  }, []);
  const showError = useCallback(() => {    setState("error");
    if (reduce) {      clearErrorClasses();      window.setTimeout(() => setState("idle"), 1600);      return;    }
    const wrap = wrapRef.current;    const zone = zoneRef.current;    if (!wrap || !zone) {      window.setTimeout(() => setState("idle"), 1600);      return;    }
    wrap.classList.add("is-error");    zone.classList.add("is-error");
    // Orthogonal shake replay (12): remove → reflow → re-add    zone.classList.remove("is-shaking");    void zone.offsetWidth;    zone.classList.add("is-shaking");    setShaking(true);
    const shakeMs =      readMs("--shake-dur-a", 80) * 2 + readMs("--shake-dur-b", 60) * 2;    window.setTimeout(() => {      zone.classList.remove("is-shaking");      setShaking(false);    }, shakeMs + 20);
    if (revertTimer.current) window.clearTimeout(revertTimer.current);    const hold = readMs("--revert-hold", 3000);    revertTimer.current = window.setTimeout(() => {      revertTimer.current = null;      wrap.classList.remove("is-error");      zone.classList.remove("is-error");      setState("idle");    }, shakeMs + hold);  }, [clearErrorClasses, reduce]);
  const emit = useCallback(    (list: FileList | null) => {      if (!list?.length || disabled) return;      const files = Array.from(list);      if (accept) {        const ok = files.every((f) => {          const types = accept.split(",").map((s) => s.trim());          return types.some((t) => {            if (t.startsWith(".")) return f.name.toLowerCase().endsWith(t);            if (t.endsWith("/*"))              return f.type.startsWith(t.replace("/*", "/"));            return f.type === t;          });        });        if (!ok) {          showError();          return;        }      }      clearErrorClasses();      onFiles?.(files);      setState("idle");    },    [accept, clearErrorClasses, disabled, onFiles, showError],  );
  return (    <div ref={wrapRef} className={cn("t-input-wrap w-full", className)}>      <motion.div        ref={zoneRef}        role="button"        tabIndex={disabled ? -1 : 0}        aria-disabled={disabled}        aria-label={label}        onClick={() => {          if (!disabled) inputRef.current?.click();        }}        onKeyDown={(e) => {          if (e.key === "Enter" || e.key === " ") {            e.preventDefault();            if (!disabled) inputRef.current?.click();          }        }}        onDragEnter={(e) => {          e.preventDefault();          if (disabled) return;          clearErrorClasses();          dragDepth.current += 1;          setState("dragging");        }}        onDragOver={(e) => e.preventDefault()}        onDragLeave={(e) => {          e.preventDefault();          dragDepth.current = Math.max(0, dragDepth.current - 1);          if (dragDepth.current === 0 && state !== "error") setState("idle");        }}        onDrop={(e) => {          e.preventDefault();          dragDepth.current = 0;          emit(e.dataTransfer.files);        }}        whileTap={disabled || reduce ? undefined : { scale: 0.96 }}        animate={          reduce            ? { opacity: state === "dragging" ? 0.85 : 1, scale: 1 }            : {                scale: state === "dragging" ? 1.02 : 1,                opacity: 1,              }        }        transition={reduce ? { duration: 0.1 } : SPRING}        className={cn(          "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",          "border-2 border-transparent",          "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",          state === "dragging"            ? "bg-[var(--color-paper-2)] ring-2 ring-[var(--color-ink)]"            : state === "error"              ? "is-error border-red-600/50 bg-[var(--color-paper)] ring-2 ring-[var(--color-ink-muted)]"              : "bg-[var(--color-paper)] ring-1 ring-dashed ring-[var(--color-rule)]",          shaking && "is-shaking",          disabled && "cursor-not-allowed opacity-50",        )}      >        <input          ref={inputRef}          type="file"          className="sr-only"          accept={accept}          multiple={multiple}          disabled={disabled}          onChange={(e) => {            emit(e.target.files);            e.target.value = "";          }}        />        <div className="flex flex-col items-center gap-2">          <span            className="t-icon-swap inline-grid size-8 place-items-center"            data-state={state === "error" ? "b" : "a"}            aria-hidden          >            <span className="t-icon" data-icon="a">              <Upload                className="size-8 text-[var(--color-ink)]"                strokeWidth={1.5}              />            </span>            <span className="t-icon" data-icon="b">              <FileWarning                className="size-8 text-[var(--color-ink-muted)]"                strokeWidth={1.5}              />            </span>          </span>          <span            ref={textRef}            className="t-text-swap text-sm font-medium text-[var(--color-ink)]"          >            {label}          </span>          {state === "idle" ? (            <span className="text-xs text-[var(--color-ink-muted)]">{hint}</span>          ) : null}        </div>      </motion.div>      <p        className="t-error-msg m-0 mt-1.5 text-center text-xs text-red-600 dark:text-red-400"        role="alert"      >        File type not accepted      </p>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { FileDropzone } from "@/components/ui/file-dropzone";
<FileDropzone  accept=".png,.jpg,.pdf"  onFiles={(files) => upload(files)}/>

Props

PropTypeRequiredDefaultDescription
onFiles(files: File[]) => voidNoFires with accepted files.
acceptstringNoMIME / extension filter (same as input accept).
multiplebooleanNotrueAllow multiple files.
disabledbooleanNoDisables drop and click.
labelstringNoPrimary idle label.

Best Practices

  1. Always pair with accept when the backend is strict.
  2. Handle upload progress elsewhere — this surface is the affordance only.
  3. Error state is brief; don’t leave the zone stuck in error.