Skip to content
Block

File upload

A drop zone for choosing files with size checks, per-file progress, and removal.

  • #arc
  • #block
  • #upload
  • #file
  • #dropzone
  • #progress
  • #form

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/file-upload.json

file-upload.module.csscss
.root { display: grid; width: 100%; min-width: 0; color: var(--foreground); }
.input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
/* A full-width drop target: press feedback is color only, never a scale. */
.dropzone { display: flex; min-height: 92px; align-items: center; gap: 12px; padding: 16px; border: 1px dashed var(--border-strong); border-radius: var(--radius-panel); background: var(--surface); cursor: pointer; transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
.dropzone.dragging, .dropzone:not(.disabled):active { border-color: var(--accent); background: var(--accent-subtle); }
.dropzone.disabled { cursor: not-allowed; opacity: .52; }
.uploadIcon, .fileIcon { display: grid; flex: 0 0 auto; place-items: center; color: var(--accent-strong); }
/* The cloud lifts toward the file while it hovers over the drop target. */
.uploadIcon { width: 24px; height: 24px; transition: transform var(--duration-spring) var(--ease-spring); }
.dragging .uploadIcon { transform: translateY(-3px); }
.copy { display: grid; min-width: 0; gap: 3px; flex: 1; }
.copy strong { font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
.label { position: relative; display: block; min-width: 0; }
.labelText { display: block; }
.copy > span { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); overflow-wrap: anywhere; }
.browse { flex: 0 0 auto; color: var(--accent-strong); font-size: var(--text-xs); font-weight: 500; }
.statusFrame { overflow: hidden; }
.status { position: relative; min-height: 16px; margin: 0; padding-top: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.statusText { display: block; }
.fileList { display: grid; margin: 0; padding: 0; list-style: none; }
/* The gap lives inside the clipped row, so a collapsing row reaches zero height instead of stopping at its padding. */
.fileRow { overflow: hidden; }
.fileItem { display: flex; margin-top: 6px; min-width: 0; align-items: center; gap: 12px; padding: 10px 10px 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-control); background: var(--surface-muted); }
.fileIcon { width: 24px; height: 24px; color: var(--text-secondary); }
.fileCopy { display: grid; min-width: 0; flex: 1; gap: 2px; }
.fileCopy strong { overflow: hidden; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }
.fileCopy > span { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.meta { display: flex; min-width: 0; white-space: nowrap; font-variant-numeric: tabular-nums; }
.dot { padding-inline: 5px; }
.phase { position: relative; display: inline-block; }
.phaseText { display: block; }
.percent { display: inline-block; min-width: 4ch; }
.fileCopy .error { color: var(--danger); }
.barFrame { display: block; }
.bar { display: block; height: 3px; margin: 5px 0 1px; overflow: hidden; border-radius: 99px; background: var(--border); }
.barFill { display: block; width: 100%; height: 100%; border-radius: inherit; background: var(--accent); }
.statusSlot { position: relative; display: grid; width: 26px; height: 26px; flex: 0 0 auto; place-items: center; }
.retrySlot { display: grid; place-items: center; }
.ready { display: grid; width: 22px; height: 22px; place-items: center; color: var(--success); }
.remove, .retry { display: grid; width: 26px; height: 26px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.retry { color: var(--text-secondary); }
/* Quick press, spring release. */
.remove:active, .retry:active { transform: scale(.96); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
@media (hover: hover) and (pointer: fine) {
  .dropzone:not(.disabled):hover { border-color: var(--accent); background: var(--accent-subtle); }
  .remove:hover, .retry:hover { background: var(--surface); color: var(--foreground); }
}
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) { .dropzone, .uploadIcon, .remove, .retry { transition: none; } .dragging .uploadIcon, .remove:active, .retry:active { transform: none; } }
file-upload.tsxtsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ChangeEvent, DragEvent, KeyboardEvent } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useMotionValueEvent, useTransform, type HTMLMotionProps, type MotionProps, type TargetAndTransition, type Transition } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { HugeiconsIcon } from "@hugeicons/react";
import { CloudUploadIcon, FileZipIcon, Image01Icon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./file-upload.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";

export type FileUploadItem = { id: string; file: File; error?: string };

export interface FileUploadProps {
  accept?: string;
  disabled?: boolean;
  label?: string;
  description?: string;
  multiple?: boolean;
  maxSize?: number;
  value?: FileUploadItem[];
  onChange?: (files: FileUploadItem[]) => void;
  /** Uploads each accepted file. Report progress from 0 to 100, resolve when done, or reject to mark the file as failed. Removing a file aborts its signal. */
  onUpload?: (file: File, options: { onProgress: (percent: number) => void; signal: AbortSignal }) => Promise<void>;
}

type Upload = { status: "uploading" | "done" | "failed"; progress: number };

const enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
const exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };
const instant: Transition = { duration: 0 };
const textIn: TargetAndTransition = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
const textOut: TargetAndTransition = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
const shown: TargetAndTransition = { opacity: 1, y: "0em", scale: 1, filter: "blur(0px)" };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };

function FileTypeIcon({ file }: { file: File }) {
  const props = { size: 16, strokeWidth: 1.8, "aria-hidden": true } as const;
  if (file.type.startsWith("image/")) return <HugeiconsIcon icon={Image01Icon} {...props} />;
  if (file.type.startsWith("text/")) return <UiIcon iconRole="file" {...props} />;
  if (file.type.includes("zip") || file.name.endsWith(".gz")) return <HugeiconsIcon icon={FileZipIcon} {...props} />;
  return <UiIcon iconRole="file" {...props} />;
}

function formatSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  const mb = bytes / (1024 * 1024);
  return `${mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1))} MB`;
}

/** Outgoing copies are hidden from assistive tech while they fade, so live text reads only the current message. */
function Swap(props: HTMLMotionProps<"span">) {
  const present = useIsPresent();
  return <motion.span {...props} aria-hidden={present ? props["aria-hidden"] : true} />;
}

function FileRow({ item, upload, reduce, onRemove, onRetry, removeRef }: { item: FileUploadItem; upload?: Upload; reduce: boolean | null; onRemove: () => void; onRetry: () => void; removeRef: (node: HTMLButtonElement | null) => void }) {
  const status = upload?.status;
  // A finished upload keeps its bar until the spring reaches the end, so 100% is seen before the label turns to Uploaded.
  const [filled, setFilled] = useState(status !== "uploading");
  const [seen, setSeen] = useState(status);
  if (seen !== status) { setSeen(status); if (status === "uploading") setFilled(false); }
  const shownStatus = status === "done" && !filled && !reduce ? "uploading" : status;
  const phase = item.error ? "invalid" : shownStatus ?? "ready";
  // One spring drives the bar and the counted percentage, so both always agree.
  const progress = useMotionValue(upload?.progress ?? 0);
  // The fill slides in from the left instead of scaling, so its rounded end keeps its shape at every value.
  const x = useTransform(progress, latest => `${Math.min(Math.max(latest, 0), 100) - 100}%`);
  const percent = useTransform(progress, latest => `${Math.round(Math.min(Math.max(latest, 0), 100))}%`);
  const target = status === "done" ? 100 : upload?.progress ?? 0;
  useEffect(() => {
    // Every upload starts from an empty bar, even a retry that failed halfway.
    if (reduce || (status === "uploading" && target === 0)) { progress.jump(target); return; }
    const controls = animate(progress, target, { ...motionTokens.spring.smooth, onComplete: status === "done" ? () => setFilled(true) : undefined });
    return () => controls.stop();
  }, [status, target, progress, reduce]);
  // The label turns as soon as the count reads 100%, without waiting out the spring's last fraction of a pixel.
  useMotionValueEvent(progress, "change", latest => { if (status === "done" && latest >= 99.5) setFilled(true); });
  const swap: MotionProps = { initial: reduce ? { opacity: 0 } : textIn, animate: shown, exit: reduce ? fadeOut : textOut, transition: reduce ? instant : enter };
  return <div className={styles.fileItem}>
    <span className={styles.fileIcon}><FileTypeIcon file={item.file} /></span>
    <span className={styles.fileCopy}>
      <strong title={item.file.name}>{item.file.name}</strong>
      <span className={styles.meta}>{formatSize(item.file.size)}{shownStatus ? <><span className={styles.dot} aria-hidden="true">·</span><span className={styles.phase}><AnimatePresence mode="popLayout" initial={false}>
        <Swap key={shownStatus} className={shownStatus === "failed" ? `${styles.phaseText} ${styles.error}` : styles.phaseText} {...swap}>{shownStatus === "uploading" ? <>업로드 중 <motion.span className={styles.percent}>{percent}</motion.span></> : shownStatus === "done" ? "업로드했어요" : "업로드하지 못했어요"}</Swap>
      </AnimatePresence></span></> : null}</span>
      {item.error && <span className={styles.error}>{item.error}</span>}
      {/* The bar fills on a spring, then folds away once the file lands. */}
      <AnimatePresence initial={false}>{shownStatus === "uploading" && <motion.span key="bar" className={styles.barFrame} initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={reduce ? fadeOut : { height: 0, opacity: 0, transition: { height: { ...motionTokens.spring.smooth, delay: motionTokens.duration.standard }, opacity: { ...exitFast, delay: motionTokens.duration.standard } } }} transition={reduce ? instant : { height: motionTokens.spring.smooth, opacity: enter }}>
        <span className={styles.bar}><motion.span className={styles.barFill} style={{ x }} /></span>
      </motion.span>}</AnimatePresence>
    </span>
    <span className={styles.statusSlot}>
      <AnimatePresence mode="popLayout" initial={false}>
        {(phase === "ready" || phase === "done") && <motion.span key="ready" className={styles.ready} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: .8 }} animate={{ opacity: 1, scale: 1 }} exit={fadeOut} transition={reduce ? instant : motionTokens.spring.snappy}>
          <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><motion.path d="M4 12.5l5 5L20 6.5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ ...enter, delay: motionTokens.delay.short }} /></svg>
          <span className={styles.srOnly}>{phase === "done" ? "업로드했어요" : "준비됐어요"}</span>
        </motion.span>}
        {/* The wrapper carries the entrance, so the button's own press scale never competes with it. */}
        {phase === "failed" && <motion.span key="retry" className={styles.retrySlot} initial={reduce ? { opacity: 0 } : iconIn} animate={shown} exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }} transition={reduce ? instant : motionTokens.spring.snappy}>
          <button type="button" className={styles.retry} aria-label={`${item.file.name} 다시 올리기`} onClick={onRetry}><UiIcon iconRole="retry" size={14} strokeWidth={1.8} aria-hidden="true" /></button>
        </motion.span>}
      </AnimatePresence>
    </span>
    <button ref={removeRef} type="button" className={styles.remove} aria-label={`${item.file.name} 삭제하기`} onClick={onRemove}><UiIcon iconRole="close" size={15} aria-hidden="true" /></button>
  </div>;
}

export function FileUpload({
  accept,
  disabled = false,
  label = "파일 올리기",
  description = "파일을 여기로 끌어다 놓거나 기기에서 찾아보세요.",
  multiple = true,
  maxSize,
  value,
  onChange,
  onUpload,
}: FileUploadProps) {
  const inputId = useId();
  const reduce = useReducedMotion();
  const inputRef = useRef<HTMLInputElement>(null);
  const dropzoneRef = useRef<HTMLDivElement>(null);
  const [internalFiles, setInternalFiles] = useState<FileUploadItem[]>([]);
  const [dragging, setDragging] = useState(false);
  const [status, setStatus] = useState("");
  const [uploads, setUploads] = useState<Record<string, Upload>>({});
  const dragDepth = useRef(0);
  const nextId = useRef(0);
  const controllers = useRef(new Map<string, AbortController>());
  const removeRefs = useRef(new Map<string, HTMLButtonElement>());
  const files = value ?? internalFiles;

  useEffect(() => {
    const active = controllers.current;
    return () => active.forEach(controller => controller.abort());
  }, []);

  function update(next: FileUploadItem[]) {
    if (value === undefined) setInternalFiles(next);
    onChange?.(next);
  }

  function startUpload(item: FileUploadItem) {
    if (!onUpload) return;
    controllers.current.get(item.id)?.abort();
    const controller = new AbortController();
    controllers.current.set(item.id, controller);
    const set = (next: (current?: Upload) => Upload) => setUploads(current => ({ ...current, [item.id]: next(current[item.id]) }));
    set(() => ({ status: "uploading", progress: 0 }));
    onUpload(item.file, {
      signal: controller.signal,
      onProgress: percent => { if (!controller.signal.aborted) set(current => ({ status: "uploading", progress: Math.min(Math.max(percent, current?.progress ?? 0), 100) })); },
    }).then(() => {
      if (controller.signal.aborted) return;
      set(() => ({ status: "done", progress: 100 }));
      setStatus(`${item.file.name} 업로드를 마쳤어요.`);
    }, () => {
      if (controller.signal.aborted) return;
      playUiSound("error");
      set(current => ({ status: "failed", progress: current?.progress ?? 0 }));
      setStatus(`${item.file.name} 업로드에 실패했어요. 다시 올려 주세요.`);
    }).finally(() => { if (controllers.current.get(item.id) === controller) controllers.current.delete(item.id); });
  }

  function addFiles(fileList: FileList | File[]) {
    const incoming = Array.from(fileList).slice(0, multiple ? undefined : 1);
    const acceptedTypes = accept?.split(",").map(type => type.trim().toLowerCase()).filter(Boolean) ?? [];
    const matchesAccept = (file: File) => acceptedTypes.length === 0 || acceptedTypes.some(type => type.startsWith(".")
      ? file.name.toLowerCase().endsWith(type)
      : type.endsWith("/*") ? file.type.startsWith(type.slice(0, -1)) : file.type === type);
    const accepted = incoming.map(file => ({
      id: `${file.name}-${file.lastModified}-${nextId.current++}`,
      file,
      error: !matchesAccept(file)
        ? "지원하지 않는 파일 형식이에요. 다른 파일을 선택해 주세요."
        : maxSize && file.size > maxSize ? `${formatSize(maxSize)}보다 큰 파일이에요. 더 작은 파일을 선택해 주세요.` : undefined,
    }));
    if (!multiple) controllers.current.forEach(controller => controller.abort());
    update(multiple ? [...files, ...accepted] : accepted);
    const invalidCount = accepted.filter(item => item.error).length;
    const validCount = accepted.length - invalidCount;
    playUiSound(invalidCount ? "error" : "success");
    const added = `파일 ${validCount}개를 추가했어요.`;
    const attention = `파일 ${invalidCount}개를 확인해 주세요.`;
    setStatus(!invalidCount ? added : validCount ? `${added} ${attention}` : attention);
    accepted.filter(item => !item.error).forEach(startUpload);
  }

  function removeFile(item: FileUploadItem) {
    playUiSound("click");
    controllers.current.get(item.id)?.abort();
    controllers.current.delete(item.id);
    // Keep keyboard focus in the list: the next row's remove button, else the previous one, else the dropzone.
    const index = files.findIndex(file => file.id === item.id);
    const neighbor = files[index + 1] ?? files[index - 1];
    update(files.filter(file => file.id !== item.id));
    setStatus(`${item.file.name} 파일을 삭제했어요.`);
    requestAnimationFrame(() => (neighbor ? removeRefs.current.get(neighbor.id) : dropzoneRef.current)?.focus());
  }

  function handleInput(event: ChangeEvent<HTMLInputElement>) {
    if (event.target.files) addFiles(event.target.files);
    event.target.value = "";
  }

  function handleDrop(event: DragEvent<HTMLDivElement>) {
    event.preventDefault();
    dragDepth.current = 0;
    setDragging(false);
    if (!disabled && event.dataTransfer.files.length) addFiles(event.dataTransfer.files);
  }

  function handleDragEnter(event: DragEvent<HTMLDivElement>) {
    event.preventDefault();
    if (!disabled) {
      dragDepth.current += 1;
      setDragging(true);
    }
  }

  function handleDragLeave(event: DragEvent<HTMLDivElement>) {
    event.preventDefault();
    dragDepth.current = Math.max(0, dragDepth.current - 1);
    if (dragDepth.current === 0) setDragging(false);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      inputRef.current?.click();
    }
  }

  const height: Transition = reduce ? instant : { height: motionTokens.spring.smooth, opacity: enter };
  return <div className={styles.root}>
    <input ref={inputRef} id={inputId} className={styles.input} type="file" accept={accept} multiple={multiple} disabled={disabled} onChange={handleInput} />
    <div
      ref={dropzoneRef}
      className={[styles.dropzone, dragging ? styles.dragging : "", disabled ? styles.disabled : ""].filter(Boolean).join(" ")}
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled}
      aria-describedby={`${inputId}-description`}
      onClick={() => !disabled && inputRef.current?.click()}
      onKeyDown={handleKeyDown}
      onDragEnter={handleDragEnter}
      onDragOver={event => event.preventDefault()}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
    >
      <span className={styles.uploadIcon}><HugeiconsIcon icon={CloudUploadIcon} size={20} strokeWidth={1.8} aria-hidden="true" /></span>
      {/* While a file hovers over the target the label says what releasing it will do. */}
      <span className={styles.copy}><strong className={styles.label}><AnimatePresence mode="popLayout" initial={false}><Swap key={dragging ? "drop" : "idle"} className={styles.labelText} initial={reduce ? { opacity: 0 } : textIn} animate={shown} exit={reduce ? fadeOut : textOut} transition={reduce ? instant : enter}>{dragging ? (multiple ? "놓아서 파일 추가하기" : "놓아서 파일 올리기") : label}</Swap></AnimatePresence></strong><span id={`${inputId}-description`}>{description}</span></span>
      <span className={styles.browse}>찾아보기</span>
    </div>
    {/* The live region stays mounted; its frame opens on the first message and each new message rises in. */}
    <motion.div className={styles.statusFrame} initial={false} animate={{ height: status ? "auto" : 0 }} transition={height}>
      <p className={styles.status} aria-live="polite" aria-atomic="true"><AnimatePresence mode="popLayout" initial={false}>{status ? <Swap key={status} className={styles.statusText} initial={reduce ? { opacity: 0 } : textIn} animate={shown} exit={reduce ? fadeOut : textOut} transition={reduce ? instant : enter}>{status}</Swap> : null}</AnimatePresence></p>
    </motion.div>
    {/* Rows open and close their own height, so the list closes the gap when a file is removed. */}
    <motion.ul className={styles.fileList} aria-label="선택한 파일" aria-hidden={files.length === 0 ? true : undefined} initial={false} animate={{ paddingTop: files.length ? 6 : 0 }} transition={reduce ? instant : motionTokens.spring.smooth}>
      <AnimatePresence initial={false}>
        {files.map(item => <motion.li key={item.id} className={styles.fileRow} initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={reduce ? fadeOut : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: exitFast } }} transition={height}>
          <FileRow item={item} upload={uploads[item.id]} reduce={reduce} onRemove={() => removeFile(item)} onRetry={() => { playUiSound("click"); startUpload(item); }} removeRef={node => { if (node) removeRefs.current.set(item.id, node); else removeRefs.current.delete(item.id); }} />
        </motion.li>)}
      </AnimatePresence>
    </motion.ul>
  </div>;
}

export default FileUpload;