File card by type that keeps the extension visible and shows upload progress.
01Preview
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @number-flow/react03Usage
import { FileThumbnail } from "@/components/ui/file-thumbnail";
<FileThumbnail
name="q3-forecast-final-reviewed-by-finance.xlsx"
size={2_400_000}
status={upload.status}
progress={upload.progress}
onRemove={() => cancel(upload.id)}
onRetry={() => retry(upload.id)}
/>04Source
"use client";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import { useEffect, useId, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
/* ------------------------------------------------------------------ file kinds */
export type FileKind = "pdf" | "image" | "sheet" | "doc" | "slides" | "code" | "archive" | "audio" | "video" | "other";
const EXT: Record<string, FileKind> = {
pdf: "pdf",
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", avif: "image", svg: "image", heic: "image",
xls: "sheet", xlsx: "sheet", csv: "sheet", tsv: "sheet", numbers: "sheet", ods: "sheet",
doc: "doc", docx: "doc", txt: "doc", md: "doc", rtf: "doc", pages: "doc", odt: "doc",
ppt: "slides", pptx: "slides", key: "slides", odp: "slides",
js: "code", jsx: "code", ts: "code", tsx: "code", py: "code", rb: "code", go: "code", rs: "code", java: "code", json: "code", yml: "code", yaml: "code", html: "code", css: "code", sql: "code", sh: "code", swift: "code", kt: "code",
zip: "archive", gz: "archive", tar: "archive", rar: "archive", "7z": "archive", dmg: "archive",
mp3: "audio", wav: "audio", m4a: "audio", ogg: "audio", flac: "audio",
mp4: "video", mov: "video", webm: "video", mkv: "video", avi: "video",
};
/** Splits "report.final.pdf" into ["report.final", "pdf"]. Dotfiles and names without a dot have no extension. */
export function splitName(name: string): [string, string] {
const i = name.lastIndexOf(".");
return i > 0 && i < name.length - 1 ? [name.slice(0, i), name.slice(i + 1)] : [name, ""];
}
export function fileKind(name: string, mime?: string): FileKind {
const ext = splitName(name)[1].toLowerCase();
if (EXT[ext]) return EXT[ext];
if (mime?.startsWith("image/")) return "image";
if (mime?.startsWith("audio/")) return "audio";
if (mime?.startsWith("video/")) return "video";
if (mime === "application/pdf") return "pdf";
return "other";
}
/** 2_400_000 → "2.4 MB". Decimal units, the way the file browser on the same machine counts. */
export function formatBytes(bytes: number) {
if (bytes < 1000) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let v = bytes / 1000;
let u = 0;
while (v >= 1000 && u < units.length - 1) {
v /= 1000;
u++;
}
return `${v >= 100 || u === 0 ? Math.round(v) : v.toFixed(1)} ${units[u]}`;
}
/* ------------------------------------------------------------------ component */
export type FileStatus = "idle" | "uploading" | "error";
export type FileThumbnailProps = Omit<React.ComponentProps<"div">, "children"> & {
name: string;
/** Bytes. */
size?: number;
/** MIME type, used when the extension doesn't say. */
type?: string;
/** Shows the picture itself for images (a blob or remote URL). */
previewUrl?: string;
/** "row" for attachments and lists, "tile" for grids. */
layout?: "row" | "tile";
status?: FileStatus;
/** 0–1 while uploading. Leave undefined for an indeterminate spinner. */
progress?: number;
/** Shown instead of the size when status is "error". */
error?: string;
/** Opening the file: the whole card becomes the target. */
href?: string;
onOpen?: () => void;
/** Adds the remove button (it reads "Cancel upload" while uploading). */
onRemove?: () => void;
onRetry?: () => void;
/** Play a short entrance when the card mounts, e.g. a file just added. */
appear?: boolean;
};
export function FileThumbnail({
name,
size,
type,
previewUrl,
layout = "row",
status = "idle",
progress,
error,
href,
onOpen,
onRemove,
onRetry,
appear = false,
className,
...rest
}: FileThumbnailProps) {
const reduce = !!useReducedMotion();
const id = useId();
const kind = fileKind(name, type);
const [, ext] = splitName(name);
const uploading = status === "uploading";
const failed = status === "error";
// A just-finished upload gets a moment: the ring completes, a check draws, then the card settles.
const [justDone, setJustDone] = useState(false);
const [prevStatus, setPrevStatus] = useState(status);
if (prevStatus !== status) {
setPrevStatus(status);
if (prevStatus === "uploading" && status === "idle") setJustDone(true);
}
useEffect(() => {
if (!justDone) return;
const t = window.setTimeout(() => setJustDone(false), 1100);
return () => window.clearTimeout(t);
}, [justDone]);
const pct = progress === undefined ? undefined : Math.min(1, Math.max(0, progress));
const meta = failed
? (error ?? "Upload failed")
: uploading
? null
: [ext ? ext.toUpperCase() : null, size !== undefined ? formatBytes(size) : null].filter(Boolean).join(" · ");
const open = href || onOpen;
const Target = href ? "a" : "button";
const targetProps = href ? { href, target: "_blank", rel: "noopener noreferrer" } : { type: "button" as const, onClick: onOpen };
const enter = appear && !reduce ? { opacity: 0, scale: 0.96, y: 4 } : false;
return (
<motion.div
role="group"
aria-labelledby={`${id}-name`}
aria-describedby={`${id}-meta`}
data-layout={layout}
data-status={justDone ? "done" : status}
data-kind={kind}
initial={enter}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, transition: { duration: 0.14, ease: ease.in } }}
transition={reduce ? { duration: 0.12 } : spring.soft}
className={cn(
"group/file relative min-w-0 rounded-xl border bg-raised shadow-[var(--shadow)] transition-[border-color,background-color] duration-150",
failed ? "border-danger/35" : "border-line",
open && "hover:border-line-2 hover:bg-hover has-[[data-target]:focus-visible]:border-fg-4",
layout === "row" ? "flex h-14 items-center gap-3 p-2 pr-1.5" : "flex w-full flex-col gap-2 p-1.5 pb-2.5",
className,
)}
{...(rest as React.ComponentProps<typeof motion.div>)}
>
{/* Preview */}
<div
className={cn(
"relative grid shrink-0 place-items-center overflow-hidden bg-hover",
layout === "row" ? "size-10 rounded-lg" : "aspect-[4/3] w-full rounded-lg",
)}
>
<Preview kind={kind} ext={ext} previewUrl={previewUrl} large={layout === "tile"} />
<AnimatePresence initial={false}>
{(uploading || justDone || failed) && (
<motion.div
key="veil"
className={cn("absolute inset-0 grid place-items-center", failed ? "bg-hover" : "bg-raised/70")}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.3, delay: 0.05 } }}
transition={{ duration: 0.15 }}
>
<ProgressRing value={justDone ? 1 : pct} state={failed ? "error" : justDone ? "done" : "active"} size={layout === "tile" ? 36 : 28} reduce={reduce} />
</motion.div>
)}
</AnimatePresence>
</div>
{/* Name and meta */}
<div className={cn("flex min-w-0 flex-col", layout === "row" ? "flex-1 gap-0.5" : "gap-0.5 px-1")}>
<MiddleName id={`${id}-name`} name={name} />
<p id={`${id}-meta`} className={cn("flex min-w-0 items-center gap-1 text-[12px] leading-4", failed ? "text-danger" : "text-fg-3")}>
{uploading ? (
<>
<span>Uploading</span>
{pct !== undefined && (
<>
<span aria-hidden>·</span>
<NumberFlow value={pct} format={{ style: "percent", maximumFractionDigits: 0 }} locales="en-US" animated={!reduce} className="tabular" />
</>
)}
</>
) : (
<span className="truncate">{meta}</span>
)}
</p>
</div>
{/* The whole card opens the file; buttons sit above this target. */}
{open && (
<Target
data-target
aria-labelledby={`${id}-name`}
className="absolute inset-0 rounded-[inherit] outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3"
{...targetProps}
/>
)}
{(onRemove || (failed && onRetry)) && (
<div
className={cn(
"z-10 flex shrink-0 items-center",
layout === "row" && "relative",
layout === "tile" &&
"absolute right-2.5 top-2.5 gap-1 rounded-lg transition-opacity duration-150 pointer-fine:opacity-0 pointer-fine:group-hover/file:opacity-100 pointer-fine:group-focus-within/file:opacity-100",
layout === "tile" && (failed || uploading) && "pointer-fine:opacity-100",
)}
>
{failed && onRetry && (
<CardButton label={`Retry uploading ${name}`} onClick={onRetry} tile={layout === "tile"}>
<path d="M13 8a5 5 0 1 1-1.5-3.55M13 2.75V5h-2.25" />
</CardButton>
)}
{onRemove && (
<CardButton label={uploading ? `Cancel upload of ${name}` : `Remove ${name}`} onClick={onRemove} tile={layout === "tile"}>
<path d="m4.5 4.5 7 7M11.5 4.5l-7 7" />
</CardButton>
)}
</div>
)}
{uploading && pct !== undefined && (
<span role="progressbar" aria-label={`Uploading ${name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct * 100)} className="sr-only" />
)}
<span role="status" aria-live="polite" className="sr-only">
{justDone ? `${name} uploaded` : failed ? `${name}: ${error ?? "upload failed"}` : ""}
</span>
</motion.div>
);
}
/* ------------------------------------------------------------------ parts */
/**
* Middle truncation that keeps the extension: the start of the name shrinks with an
* ellipsis, the last few characters and the extension never do. Pure CSS, so it
* follows the card's width. The full name stays in the title and the accessible name.
*/
function MiddleName({ name, id }: { name: string; id: string }) {
const [base, ext] = splitName(name);
const keep = Math.min(base.length, 6);
const head = base.slice(0, base.length - keep);
const tail = base.slice(base.length - keep) + (ext ? `.${ext}` : "");
return (
<p id={id} title={name} className="flex min-w-0 text-[13px] font-medium leading-[18px] tracking-[-0.005em] text-fg">
<span aria-hidden className="truncate">{head}</span>
<span aria-hidden className="shrink-0 whitespace-pre">{tail}</span>
<span className="sr-only">{name}</span>
</p>
);
}
function Preview({ kind, ext, previewUrl, large }: { kind: FileKind; ext: string; previewUrl?: string; large: boolean }) {
const [loaded, setLoaded] = useState<string | null>(null);
const [broken, setBroken] = useState<string | null>(null);
if (previewUrl && broken !== previewUrl) {
return (
<>
<Glyph kind={kind} ext={ext} large={large} />
{/* eslint-disable-next-line @next/next/no-img-element -- previews are often local blob URLs */}
<img
src={previewUrl}
alt=""
ref={(i) => {
if (i?.complete && i.naturalWidth && loaded !== previewUrl) setLoaded(previewUrl);
}}
onLoad={() => setLoaded(previewUrl)}
onError={() => setBroken(previewUrl)}
className={cn(
"absolute inset-0 size-full object-cover transition-opacity duration-200 ease-out-quart motion-reduce:transition-none",
loaded === previewUrl ? "opacity-100" : "opacity-0",
)}
/>
</>
);
}
return <Glyph kind={kind} ext={ext} large={large} />;
}
// A page with a folded corner, a mark for the kind, and the extension on a tab.
function Glyph({ kind, ext, large }: { kind: FileKind; ext: string; large: boolean }) {
const mark: Record<FileKind, React.ReactNode> = {
pdf: <path d="M7 11.5h10M7 14.5h10M7 17.5h6" />,
doc: <path d="M7 11.5h10M7 14.5h10M7 17.5h7" />,
sheet: <path d="M7 11h10v8H7zM7 15h10M11.5 11v8" />,
slides: <path d="M7 11.5h10v6H7zM12 17.5v2" />,
code: <path d="m10 12-2.5 2.75L10 17.5M14 12l2.5 2.75L14 17.5" />,
image: <path d="m7 18 3.25-3.5 2.25 2.25 1.5-1.5L17 18M13.5 11.75a.25.25 0 1 1 0 .01" />,
archive: <path d="M11 4.5v1.5M13 6v1.5M11 7.5V9M13 9v1.5M11 10.5V12M11.25 13.5h1.5v2.5h-1.5z" />,
audio: <path d="M8 14.5v1M10.25 12.5v5M12.5 11v8M14.75 13v4M17 14.25v1.5" />,
video: <path d="m10.5 12 5 2.75-5 2.75z" />,
other: <path d="M7 12.5h10M7 15.5h6" />,
};
const label = ext ? ext.slice(0, 4).toUpperCase() : "";
return (
<span aria-hidden className={cn("relative grid place-items-center", large ? "h-[52px] w-[42px]" : "h-[30px] w-6")}>
<svg viewBox="0 0 24 30" className="absolute inset-0 size-full text-fg-3" fill="none" stroke="currentColor" strokeWidth={large ? 1 : 1.3} strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 1.5h10l5.5 5.5v19.5a2 2 0 0 1-2 2H4.5a2 2 0 0 1-2-2v-23a2 2 0 0 1 2-2z" className="fill-raised" />
<path d="M14.5 1.5V5a2 2 0 0 0 2 2h3.5" />
<g className="text-fg-2" stroke="currentColor">
{mark[kind]}
</g>
</svg>
{label && (
<span
className={cn(
"absolute left-[-3px] rounded-[3px] bg-fg font-mono font-medium leading-none tracking-[0.02em] text-frame",
large ? "bottom-[7px] px-1 py-[2.5px] text-[8.5px]" : "bottom-[3px] px-[3px] py-[1.5px] text-[6.5px]",
)}
>
{label}
</span>
)}
</span>
);
}
/** Determinate ring that springs to each new value, a spinning arc when the total is unknown, a drawn check or cross at the end. */
function ProgressRing({ value, state, size, reduce }: { value?: number; state: "active" | "done" | "error"; size: number; reduce: boolean }) {
const r = 10;
const c = 2 * Math.PI * r;
const sprung = useSpring(value ?? 0, reduce ? { duration: 0 } : { stiffness: 140, damping: 24 });
useEffect(() => {
sprung.set(value ?? 0);
}, [value, sprung]);
const offset = useTransform(sprung, (v) => c * (1 - v));
const draw = reduce ? { duration: 0 } : { duration: 0.3, ease: ease.out, delay: 0.1 };
return (
<span className={cn("grid place-items-center rounded-full", state === "error" ? "text-danger" : "text-fg")} style={{ width: size, height: size }}>
<svg viewBox="0 0 24 24" className="size-full -rotate-90" fill="none">
<circle cx="12" cy="12" r={r} stroke="currentColor" strokeOpacity={0.18} strokeWidth={2} />
{state !== "error" &&
(value === undefined && state === "active" ? (
<circle cx="12" cy="12" r={r} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeDasharray={`${c * 0.28} ${c}`} className="origin-center animate-spin [animation-duration:0.9s]" />
) : (
<motion.circle cx="12" cy="12" r={r} stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeDasharray={c} style={{ strokeDashoffset: offset }} />
))}
</svg>
<svg viewBox="0 0 16 16" className="absolute size-[45%]" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{state === "done" && <motion.path d="M3.5 8.5 6.5 11.5 12.5 4.5" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={draw} />}
{state === "error" && (
<>
<motion.path d="m5 5 6 6" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={draw} />
<motion.path d="m11 5-6 6" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ ...draw, delay: reduce ? 0 : 0.2 }} />
</>
)}
</svg>
</span>
);
}
function CardButton({ label, onClick, tile, children }: { label: string; onClick: () => void; tile: boolean; children: React.ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={cn(
"relative grid place-items-center rounded-md outline-none",
"transition-[background-color,color,scale] duration-150 ease-out-quart active:scale-[0.9] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
tile ? "size-6 border border-line-2 bg-raised/90 text-fg-2 shadow-[var(--shadow)] backdrop-blur-sm hover:text-fg" : "size-7 text-fg-3 hover:bg-fg/[0.06] hover:text-fg",
)}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{children}
</svg>
</button>
);
}05Props
FileThumbnail
| Prop | Type | Default | Description |
|---|---|---|---|
| name* | string | — | File name. The start truncates in the middle; the last characters and the extension always show. |
| size | number | — | Bytes, shown as "2.4 MB". |
| type | string | — | MIME type, used when the extension doesn't identify the kind. |
| previewUrl | string | — | Shows the image itself, fading in over the type glyph once decoded. |
| layout | "row" | "tile" | "row" | A 56px row for attachments and lists, or a card with a 4:3 preview for grids. |
| status | "idle" | "uploading" | "error" | "idle" | Uploading veils the preview with a progress ring; error turns it red with a cross. |
| progress | number | — | 0–1 while uploading. Undefined shows a spinning arc instead. |
| error | string | "Upload failed" | Replaces the size when status is error. |
| href | string | — | Makes the whole card a link that opens the file in a new tab. |
| onOpen | () => void | — | Makes the whole card a button. |
| onRemove | () => void | — | Adds the remove button, labeled Cancel upload while uploading. |
| onRetry | () => void | — | Adds a retry button when the upload failed. |
| appear | boolean | false | Enter with a short rise and scale, for a file that was just added. |
fileKind / formatBytes / splitName
| Prop | Type | Default | Description |
|---|---|---|---|
| fileKind(name, mime?) | FileKind | — | pdf, image, sheet, doc, slides, code, archive, audio, video or other. |
| formatBytes(bytes) | string | — | Decimal units: 18400 → "18 KB", 2400000 → "2.4 MB". |
| splitName(name) | [base, ext] | — | Dotfiles and names without a dot have no extension. |
06Notes
Behavior
- Middle truncation is pure CSS: the head of the name shrinks with an ellipsis while the last six characters and the extension never do, so two long names from the same export stay tellable apart.
- Finishing an upload plays its own moment (the ring completes, a check draws) for about a second before the card settles to the file's size.
- The whole card is the open target when href or onOpen is set; remove and retry sit above it. On a tile they appear on hover or focus, and are always visible on touch or when something needs attention.
- A preview that fails to load falls back to the type glyph at the same size, never a broken image.
Motion
- Progress springs to each reported value (stiffness 140, damping 24), so bursty upload events read as steady motion; the percentage rolls with NumberFlow.
- The check and the error cross draw their paths in 300ms; the veil fades in 150ms and out over 300ms once done.
- Removal exits at 96% scale in 140ms; with appear, new files rise 4px from 96% on the soft spring. Reduced motion keeps fades and sets the ring directly.
Accessibility
- Each card is a group named by the full file name and described by its size or status; the truncated name is hidden from assistive tech in favor of the whole one.
- Uploads expose a progressbar with the percentage; completion and failure are announced politely.
- Buttons carry the file name ("Cancel upload of brand-guidelines-2026.pdf") and have 44px touch targets.