블록
파일 업로드
끌어다 놓거나 골라서 올리고 진행률과 오류를 보여 주는 업로드 블록이에요.
- #arc
- #block
- #upload
- #file
- #dropzone
- #progress
- #form
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/file-upload.json파일
components/arc/blocks/file-upload/file-upload.module.csscomponents/arc/blocks/file-upload/file-upload.tsx
npm 패키지
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
함께 오는 항목
file-upload.module.cssfile-upload.module.cssfile-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.tsxfile-upload.tsxfile-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;