Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AlertCircle, Check, File as FileIcon, FileArchive, FileText, Film, Image as ImageIcon, Music, RotateCcw, UploadCloud, X } from "lucide-react";
import { cn } from "@/lib/utils";

export type UploadStatus = "uploading" | "done" | "error" | "rejected";

export type UploadItem = {
  id: string;
  file: File;
  status: UploadStatus;
  progress: number;
  error?: string;
  previewUrl?: string;
  attempt: number;
};

export type UploadFn = (file: File, onProgress: (pct: number) => void, signal: AbortSignal) => Promise<void>;

export interface FileDropzoneHandle {
  addFiles: (files: File[]) => void;
  clear: () => void;
}

export interface FileDropzoneProps {
  /** MIME types / wildcards / extensions, e.g. ["image/*", "application/pdf", ".zip"]. */
  accept?: string[];
  /** Bytes. */
  maxSize?: number;
  maxFiles?: number;
  multiple?: boolean;
  /** Real uploader. Omit to use the built-in progress simulation. */
  upload?: UploadFn;
  /** Simulation only: return true to make an attempt fail. */
  simulateFailure?: (file: File, attempt: number) => boolean;
  onChange?: (items: UploadItem[]) => void;
  title?: string;
  hint?: string;
  disabled?: boolean;
  ref?: React.Ref<FileDropzoneHandle>;
  className?: string;
}

/* ---------------------------------------------------------------------------- */

export function formatBytes(n: number) {
  if (n < 1024) return `${n} B`;
  const u = ["KB", "MB", "GB"];
  let v = n / 1024;
  let i = 0;
  while (v >= 1024 && i < u.length - 1) {
    v /= 1024;
    i++;
  }
  return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
}

function matchesAccept(file: { name: string; type: string }, accept?: string[]) {
  if (!accept?.length) return true;
  const name = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  return accept.some((a) => {
    const rule = a.trim().toLowerCase();
    if (rule.startsWith(".")) return name.endsWith(rule);
    if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
    return type === rule;
  });
}

function describeAccept(accept?: string[]) {
  if (!accept?.length) return "Any file";
  return accept
    .map((a) => (a.endsWith("/*") ? a.slice(0, -2).replace(/^\w/, (c) => c.toUpperCase()) + "s" : a.startsWith(".") ? a.slice(1).toUpperCase() : (a.split("/")[1] ?? a).toUpperCase()))
    .join(", ");
}

const simulated =
  (fail?: (f: File, attempt: number) => boolean) =>
  (attempt: number): UploadFn =>
  (file, onProgress, signal) =>
    new Promise<void>((resolve, reject) => {
      // Larger files take longer; capped so the demo stays snappy.
      const total = Math.min(4200, 900 + file.size / 900);
      const willFail = fail?.(file, attempt) ?? false;
      const failAt = 0.35 + ((file.size % 37) / 37) * 0.4;
      let elapsed = 0;
      const tick = 60;
      const id = window.setInterval(() => {
        elapsed += tick * (0.6 + (Math.sin(elapsed / 180) + 1) * 0.5); // uneven, network-like
        const pct = Math.min(1, elapsed / total);
        if (willFail && pct >= failAt) {
          window.clearInterval(id);
          reject(new Error("Network error — connection reset"));
          return;
        }
        onProgress(pct);
        if (pct >= 1) {
          window.clearInterval(id);
          resolve();
        }
      }, tick);
      signal.addEventListener("abort", () => {
        window.clearInterval(id);
        reject(new DOMException("Aborted", "AbortError"));
      });
    });

function kindIcon(file: File) {
  const t = file.type;
  if (t.startsWith("image/")) return { Icon: ImageIcon, tint: "from-violet-500 to-indigo-500" };
  if (t.startsWith("video/")) return { Icon: Film, tint: "from-fuchsia-500 to-pink-500" };
  if (t.startsWith("audio/")) return { Icon: Music, tint: "from-emerald-500 to-teal-500" };
  if (t === "application/pdf" || t.startsWith("text/")) return { Icon: FileText, tint: "from-rose-500 to-orange-500" };
  if (/zip|compressed|tar|rar/.test(t) || /\.(zip|rar|7z|tar|gz)$/i.test(file.name)) return { Icon: FileArchive, tint: "from-amber-500 to-yellow-500" };
  return { Icon: FileIcon, tint: "from-sky-500 to-indigo-500" };
}

/* ---------------------------------------------------------------------------- */

export function FileDropzone({
  accept = ["image/*", "application/pdf", ".zip"],
  maxSize = 5 * 1024 * 1024,
  maxFiles = 8,
  multiple = true,
  upload,
  simulateFailure,
  onChange,
  title = "Drop files to upload",
  hint,
  disabled = false,
  ref,
  className,
}: FileDropzoneProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const [items, setItems] = React.useState<UploadItem[]>([]);
  const [drag, setDrag] = React.useState<"idle" | "over" | "invalid">("idle");
  const [announce, setAnnounce] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);
  const depth = React.useRef(0);
  const controllers = React.useRef(new Map<string, AbortController>());
  const urls = React.useRef(new Set<string>());
  const seq = React.useRef(0);
  const countRef = React.useRef(0);

  const update = React.useCallback(
    (fn: (prev: UploadItem[]) => UploadItem[]) =>
      setItems((prev) => {
        const next = fn(prev);
        countRef.current = next.filter((i) => i.status !== "rejected").length;
        return next;
      }),
    [],
  );

  React.useEffect(() => {
    onChange?.(items);
  }, [items, onChange]);

  // revoke previews + abort uploads on unmount
  React.useEffect(() => {
    const c = controllers.current;
    const u = urls.current;
    return () => {
      c.forEach((x) => x.abort());
      u.forEach((x) => URL.revokeObjectURL(x));
    };
  }, []);

  const start = React.useCallback(
    (id: string, file: File, attempt: number) => {
      const ctrl = new AbortController();
      controllers.current.set(id, ctrl);
      const run = upload ?? simulated(simulateFailure)(attempt);
      run(
        file,
        (pct) => update((prev) => prev.map((it) => (it.id === id ? { ...it, progress: Math.max(it.progress, pct) } : it))),
        ctrl.signal,
      )
        .then(() => {
          controllers.current.delete(id);
          update((prev) => prev.map((it) => (it.id === id ? { ...it, status: "done", progress: 1 } : it)));
          setAnnounce(`${file.name} uploaded`);
        })
        .catch((err: unknown) => {
          controllers.current.delete(id);
          if (ctrl.signal.aborted) return;
          const msg = err instanceof Error ? err.message : "Upload failed";
          update((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error", error: msg } : it)));
          setAnnounce(`${file.name} failed: ${msg}`);
        });
    },
    [upload, simulateFailure, update],
  );

  const addFiles = React.useCallback(
    (files: File[]) => {
      if (disabled) return;
      const list = multiple ? files : files.slice(0, 1);
      let room = maxFiles - countRef.current;
      const fresh: UploadItem[] = list.map((file) => {
        const id = `${uid}-${seq.current++}`;
        let error: string | undefined;
        if (!matchesAccept(file, accept)) error = "File type not allowed";
        else if (file.size > maxSize) error = `Too large — max ${formatBytes(maxSize)}`;
        else if (room <= 0) error = `Limit of ${maxFiles} files reached`;
        else room--;
        let previewUrl: string | undefined;
        if (!error && file.type.startsWith("image/")) {
          previewUrl = URL.createObjectURL(file);
          urls.current.add(previewUrl);
        }
        return { id, file, status: error ? "rejected" : "uploading", progress: 0, error, previewUrl, attempt: 1 };
      });
      update((prev) => (multiple ? [...fresh, ...prev] : fresh));
      fresh.filter((f) => f.status === "uploading").forEach((f) => start(f.id, f.file, 1));
      const bad = fresh.filter((f) => f.status === "rejected").length;
      setAnnounce(`${fresh.length - bad} file(s) added${bad ? `, ${bad} rejected` : ""}`);
    },
    [disabled, multiple, maxFiles, uid, accept, maxSize, update, start],
  );

  const remove = (id: string) => {
    controllers.current.get(id)?.abort();
    controllers.current.delete(id);
    update((prev) => {
      const it = prev.find((x) => x.id === id);
      if (it?.previewUrl) {
        URL.revokeObjectURL(it.previewUrl);
        urls.current.delete(it.previewUrl);
      }
      return prev.filter((x) => x.id !== id);
    });
  };

  const retry = (id: string) => {
    const it = items.find((x) => x.id === id);
    if (!it) return;
    const attempt = it.attempt + 1;
    update((prev) => prev.map((x) => (x.id === id ? { ...x, status: "uploading", progress: 0, error: undefined, attempt } : x)));
    start(id, it.file, attempt);
  };

  React.useImperativeHandle(
    ref,
    () => ({
      addFiles,
      clear: () => {
        controllers.current.forEach((c) => c.abort());
        controllers.current.clear();
        urls.current.forEach((u) => URL.revokeObjectURL(u));
        urls.current.clear();
        update(() => []);
      },
    }),
    [addFiles, update],
  );

  /* drag handlers */
  const onDragEnter = (e: React.DragEvent) => {
    if (disabled || !e.dataTransfer.types.includes("Files")) return;
    e.preventDefault();
    depth.current++;
    const types = Array.from(e.dataTransfer.items).map((i) => ({ name: "", type: i.type }));
    // extensions are unknown until drop; only flag MIME-typed mismatches
    const invalid = types.some((t) => t.type && !accept.some((a) => a.startsWith(".")) && !matchesAccept(t, accept));
    setDrag(invalid ? "invalid" : "over");
  };
  const onDragOver = (e: React.DragEvent) => {
    if (disabled || !e.dataTransfer.types.includes("Files")) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = drag === "invalid" ? "none" : "copy";
  };
  const onDragLeave = () => {
    depth.current = Math.max(0, depth.current - 1);
    if (depth.current === 0) setDrag("idle");
  };
  const onDrop = (e: React.DragEvent) => {
    e.preventDefault();
    depth.current = 0;
    setDrag("idle");
    if (e.dataTransfer.files.length) addFiles(Array.from(e.dataTransfer.files));
  };

  const accepted = items.filter((i) => i.status !== "rejected");
  const doneCount = items.filter((i) => i.status === "done").length;
  const uploading = items.filter((i) => i.status === "uploading");
  const overall = uploading.length ? uploading.reduce((s, i) => s + i.progress, 0) / uploading.length : 0;
  const hintText = hint ?? `${describeAccept(accept)} · up to ${formatBytes(maxSize)} each · max ${maxFiles} files`;
  const over = drag !== "idle";
  const compact = items.length > 0;

  return (
    <div className={cn("w-full max-w-xl", className)} onPaste={(e) => e.clipboardData.files.length && addFiles(Array.from(e.clipboardData.files))}>
      <input
        ref={inputRef}
        id={`${uid}-input`}
        type="file"
        multiple={multiple}
        accept={accept.join(",")}
        className="sr-only"
        tabIndex={-1}
        aria-hidden
        onChange={(e) => {
          if (e.target.files) addFiles(Array.from(e.target.files));
          e.target.value = "";
        }}
      />
      <motion.button
        type="button"
        disabled={disabled}
        onClick={() => inputRef.current?.click()}
        onDragEnter={onDragEnter}
        onDragOver={onDragOver}
        onDragLeave={onDragLeave}
        onDrop={onDrop}
        layout={!reduce}
        animate={over && !reduce ? { scale: 1.015 } : { scale: 1 }}
        transition={{ type: "spring", stiffness: 400, damping: 32 }}
        aria-describedby={`${uid}-hint`}
        className={cn(
          "group relative flex w-full items-center justify-center overflow-hidden rounded-2xl outline-none transition-colors",
          compact ? "flex-row gap-4 px-5 py-4 text-left" : "flex-col gap-3 px-6 py-9 text-center",
          "focus-visible:ring-4 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50",
          drag === "over" ? "bg-primary/[0.06]" : drag === "invalid" ? "bg-destructive/[0.06]" : "bg-muted/30 hover:bg-muted/60",
        )}
      >
        {/* animated dashed border */}
        <svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible">
          <motion.rect
            x="1"
            y="1"
            rx="15"
            style={{ width: "calc(100% - 2px)", height: "calc(100% - 2px)" }}
            fill="none"
            strokeWidth={over ? 2 : 1.5}
            strokeDasharray="7 7"
            className={cn(
              "transition-colors",
              drag === "over" ? "stroke-primary" : drag === "invalid" ? "stroke-destructive" : "stroke-foreground/20 group-hover:stroke-foreground/35",
            )}
            animate={over && !reduce ? { strokeDashoffset: [0, -28] } : { strokeDashoffset: 0 }}
            transition={over ? { duration: 0.8, ease: "linear", repeat: Infinity } : { duration: 0 }}
          />
        </svg>
        {/* glow */}
        <motion.span
          aria-hidden
          className="pointer-events-none absolute left-1/2 top-1/2 size-64 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/20 blur-3xl"
          animate={{ opacity: drag === "over" ? 1 : 0, scale: drag === "over" ? 1 : 0.6 }}
        />
        <motion.span
          layout={!reduce}
          animate={over && !reduce ? { y: -6, scale: 1.08 } : { y: 0, scale: 1 }}
          transition={{ type: "spring", stiffness: 380, damping: 18 }}
          className={cn(
            "relative grid shrink-0 place-items-center rounded-2xl border bg-background shadow-sm",
            compact ? "size-11" : "size-14",
            drag === "invalid" ? "text-destructive" : "text-primary",
          )}
        >
          {drag === "invalid" ? <AlertCircle className="size-6" /> : <UploadCloud className="size-6" />}
          {!reduce && drag === "idle" && (
            <motion.span
              aria-hidden
              className="absolute inset-0 rounded-2xl border border-primary/40"
              animate={{ scale: [1, 1.35], opacity: [0.6, 0] }}
              transition={{ duration: 2.2, repeat: Infinity, ease: "easeOut" }}
            />
          )}
        </motion.span>
        <motion.span layout={!reduce ? "position" : false} className={cn("relative min-w-0", compact ? "flex-1" : "space-y-1")}>
          <span className="block text-sm font-semibold">
            {drag === "over" ? "Release to upload" : drag === "invalid" ? "Some files aren’t supported" : title}
          </span>
          <span className="block text-sm text-muted-foreground">
            or <span className="font-medium text-primary underline-offset-4 group-hover:underline">browse your device</span>
          </span>
          {compact && (
            <span id={`${uid}-hint`} className="mt-0.5 block truncate text-xs text-muted-foreground/80">
              {hintText}
            </span>
          )}
        </motion.span>
        {!compact && (
          <span id={`${uid}-hint`} className="relative text-xs text-muted-foreground/80">
            {hintText}
          </span>
        )}
      </motion.button>

      {items.length > 0 && (
        <div className="mt-3">
          <div className="mb-2 flex items-center justify-between px-1 text-xs text-muted-foreground">
            <span>
              {accepted.length} {accepted.length === 1 ? "file" : "files"} · {doneCount} uploaded
            </span>
            {uploading.length > 0 && <span className="tabular-nums">{Math.round(overall * 100)}% · {uploading.length} in progress</span>}
          </div>
          <ul className="flex flex-col gap-2" aria-label="Files">
            <AnimatePresence initial={false}>
              {items.map((it) => (
                <FileRow key={it.id} item={it} reduce={!!reduce} onRemove={() => remove(it.id)} onRetry={() => retry(it.id)} />
              ))}
            </AnimatePresence>
          </ul>
        </div>
      )}
      <span className="sr-only" aria-live="polite">
        {announce}
      </span>
    </div>
  );
}

function FileRow({ item, reduce, onRemove, onRetry }: { item: UploadItem; reduce: boolean; onRemove: () => void; onRetry: () => void }) {
  const { file, status, progress, previewUrl, error } = item;
  const { Icon, tint } = kindIcon(file);
  const bad = status === "error" || status === "rejected";
  return (
    <motion.li
      layout={!reduce}
      initial={{ opacity: 0, y: -8, scale: 0.98 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      exit={{ opacity: 0, x: reduce ? 0 : 24, transition: { duration: 0.18 } }}
      transition={{ type: "spring", stiffness: 480, damping: 36 }}
      className={cn(
        "relative flex items-center gap-3 overflow-hidden rounded-xl border bg-card p-2 pr-1.5 text-card-foreground shadow-xs",
        bad && "border-destructive/30 bg-destructive/[0.04]",
      )}
    >
      <span className="relative size-10 shrink-0 overflow-hidden rounded-lg">
        {previewUrl ? (
          <img src={previewUrl} alt="" className="size-full object-cover" />
        ) : (
          <span className={cn("grid size-full place-items-center bg-gradient-to-br text-white", tint)}>
            <Icon className="size-5" aria-hidden />
          </span>
        )}
        <AnimatePresence>
          {status === "done" && (
            <motion.span
              initial={{ scale: 0 }}
              animate={{ scale: 1 }}
              exit={{ scale: 0 }}
              transition={{ type: "spring", stiffness: 600, damping: 20 }}
              className="absolute bottom-0.5 right-0.5 grid size-4 place-items-center rounded-full bg-emerald-500 text-white ring-2 ring-card"
            >
              <Check className="size-2.5" strokeWidth={3.5} aria-hidden />
            </motion.span>
          )}
        </AnimatePresence>
      </span>
      <div className="min-w-0 flex-1">
        <p className="truncate text-sm font-medium" title={file.name}>
          {file.name}
        </p>
        <p className={cn("mt-0.5 truncate text-xs", bad ? "text-destructive" : "text-muted-foreground")}>
          {formatBytes(file.size)}
          <span className="mx-1 opacity-50">·</span>
          {status === "uploading" ? `Uploading ${Math.round(progress * 100)}%` : status === "done" ? "Uploaded" : error}
        </p>
        {(status === "uploading" || status === "error") && (
          <div
            role="progressbar"
            aria-label={`${file.name} upload progress`}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={Math.round(progress * 100)}
            className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted"
          >
            <motion.div
              className={cn("h-full rounded-full", status === "error" ? "bg-destructive" : "bg-gradient-to-r from-primary/70 to-primary")}
              initial={false}
              animate={{ width: `${Math.max(3, progress * 100)}%` }}
              transition={{ type: "spring", stiffness: 140, damping: 24 }}
            />
          </div>
        )}
      </div>
      <div className="flex shrink-0 items-center gap-0.5">
        {status === "error" && (
          <button
            type="button"
            onClick={onRetry}
            aria-label={`Retry ${file.name}`}
            className="grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
          >
            <RotateCcw className="size-4" />
          </button>
        )}
        <button
          type="button"
          onClick={onRemove}
          aria-label={status === "uploading" ? `Cancel ${file.name}` : `Remove ${file.name}`}
          className="grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
        >
          <X className="size-4" />
        </button>
      </div>
    </motion.li>
  );
}

More in Forms & Inputs

View all →