File upload
A controlled file upload — a drop zone with a queue of rows that slide in, fill a progress bar and settle on done or error, or a compact chat-composer wrapper with a drop overlay and chips.
q3-invoices.csv
180 KB
Uploadedmember-handbook.pdf
2.3 MB
Uploading
pnpm exec shadcn add @intelligo/file-uploadInside an app made by intelligo create, or any shadcn project that names the registry.
used by these pages
No block installs it yet; it is there for your own screens.
more in motion
Popover morph, Select morph, Morphing modal, Notification stack, Animated list, OTP input, Expandable tabs, Hold action button
Source
What shadcn add writes to components/ui/file-upload.tsx. It is yours from then on.
"use client";
/*
* File upload: a drop zone that lifts under a dragged file and a queue of
* rows that slide in, fill a progress bar, settle on done or error and
* fold away on remove — or, compact, a chat composer wrapper with a drop
* overlay and a row of chips. The list is controlled; the component
* validates what arrives and reports it.
*/
import * as React from "react";
import {
AlertCircleIcon,
CheckCircle2Icon,
FileArchiveIcon,
FileAudioIcon,
FileCode2Icon,
FileIcon,
FileImageIcon,
FileSpreadsheetIcon,
FileTextIcon,
FileVideoIcon,
PaperclipIcon,
RotateCcwIcon,
UploadIcon,
XIcon,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_PRESS,
} from "@showcase/components/ui/ai-motion";
import { Button } from "@showcase/components/ui/button";
import { Spinner } from "@showcase/components/ui/spinner";
import { cn } from "@showcase/lib/utils";
/* ----------------------------------------------------------------------------
* The item shape and helpers.
* ------------------------------------------------------------------------- */
export type FileUploadStatus = "uploading" | "done" | "error";
export type FileRejectReason = "too-large" | "not-accepted" | "max-files";
export interface FileUploadItem {
id: string;
/** The picked file; `name`, `size` and `type` fall back to it. */
file?: File;
name?: string;
size?: number;
type?: string;
/** 0–100 while uploading; omit for an indeterminate upload. */
progress?: number;
status: FileUploadStatus;
error?: string;
/** An image thumbnail (an object URL or a remote URL). */
previewUrl?: string;
}
export interface FileRejection {
file: File;
reason: FileRejectReason;
}
/** An item for a freshly picked file, ready to be put into the list. */
export function createFileUploadItem(file: File): FileUploadItem {
const id =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}-${file.name}`;
return { id, file, status: "uploading", progress: 0 };
}
export function formatFileSize(bytes: number | undefined) {
if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0)
return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const exponent = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1
);
const value = bytes / 1024 ** exponent;
return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[exponent]}`;
}
function itemName(item: FileUploadItem) {
return item.name ?? item.file?.name ?? "";
}
function itemSize(item: FileUploadItem) {
return item.size ?? item.file?.size;
}
function itemType(item: FileUploadItem) {
return item.type ?? item.file?.type ?? "";
}
function extensionOf(name: string) {
return name.includes(".") ? (name.split(".").pop()?.toLowerCase() ?? "") : "";
}
function iconFor(item: FileUploadItem) {
const type = itemType(item);
const ext = extensionOf(itemName(item));
if (type.startsWith("image/")) return FileImageIcon;
if (type.startsWith("video/")) return FileVideoIcon;
if (type.startsWith("audio/")) return FileAudioIcon;
if (
/zip|compressed/.test(type) ||
["zip", "rar", "7z", "tar", "gz"].includes(ext)
)
return FileArchiveIcon;
if (/spreadsheet|excel/.test(type) || ["csv", "xls", "xlsx"].includes(ext))
return FileSpreadsheetIcon;
if (
type.includes("pdf") ||
type.startsWith("text/") ||
["pdf", "doc", "docx", "md", "txt"].includes(ext)
)
return FileTextIcon;
if (
[
"css",
"html",
"js",
"jsx",
"json",
"mdx",
"ts",
"tsx",
"xml",
"yaml",
"yml",
].includes(ext)
)
return FileCode2Icon;
return FileIcon;
}
/** Whether a file matches an `accept` string (MIME types, `type/*`, `.ext`). */
function matchesAccept(file: File, accept: string | undefined) {
if (!accept) return true;
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
return accept
.split(",")
.map((token) => token.trim().toLowerCase())
.filter(Boolean)
.some((token) => {
if (token.startsWith(".")) return name.endsWith(token);
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
return type === token;
});
}
/* ----------------------------------------------------------------------------
* Labels. Every visible or announced string, with an English default.
* ------------------------------------------------------------------------- */
export interface FileUploadLabels {
title: string;
/** Under the title; defaults to the size limit when `maxSize` is set. */
description?: string;
browse: string;
limitReached: string;
dropOverlay: string;
attach: string;
input: string;
maxSize: (size: string) => string;
remove: (name: string) => string;
retry: (name: string) => string;
progress: (name: string) => string;
status: Record<FileUploadStatus, string>;
}
const DEFAULT_LABELS: FileUploadLabels = {
title: "Drop files here",
browse: "Browse",
limitReached: "Upload limit reached",
dropOverlay: "Drop files to attach",
attach: "Attach files",
input: "Upload files",
maxSize: (size) => `Up to ${size} per file`,
remove: (name) => `Remove ${name}`,
retry: (name) => `Retry ${name}`,
progress: (name) => `${name} upload progress`,
status: { uploading: "Uploading", done: "Uploaded", error: "Failed" },
};
/* ----------------------------------------------------------------------------
* Context: the hidden input and the list, shared with the trigger.
* ------------------------------------------------------------------------- */
interface FileUploadContextValue {
open: () => void;
disabled: boolean;
labels: FileUploadLabels;
}
const FileUploadContext = React.createContext<FileUploadContextValue | null>(
null
);
function useFileUploadContext(part: string) {
const context = React.useContext(FileUploadContext);
if (!context) throw new Error(`${part} must be used inside <FileUpload>.`);
return context;
}
/* ----------------------------------------------------------------------------
* FileUpload.
* ------------------------------------------------------------------------- */
export interface FileUploadProps {
/** The list — controlled; add and remove through the callbacks. */
files: FileUploadItem[];
/** Files that passed validation. Create items with `createFileUploadItem`. */
onFilesAdded?: (files: File[]) => void;
/** Files turned away, with why. */
onFilesRejected?: (rejections: FileRejection[]) => void;
onRemove?: (item: FileUploadItem) => void;
/** Offers a retry on failed items when given. */
onRetry?: (item: FileUploadItem) => void;
accept?: string;
/** Largest accepted file, in bytes. */
maxSize?: number;
maxFiles?: number;
multiple?: boolean;
disabled?: boolean;
/**
* "default": a drop zone with a queue of rows. "compact": wraps a chat
* composer (the children) with a drop overlay and a row of chips; open
* the picker with `<FileUploadTrigger>` inside it.
*/
variant?: "default" | "compact";
labels?: Partial<FileUploadLabels>;
className?: string;
children?: React.ReactNode;
}
export function FileUpload({
files,
onFilesAdded,
onFilesRejected,
onRemove,
onRetry,
accept,
maxSize,
maxFiles,
multiple = true,
disabled = false,
variant = "default",
labels: labelsProp,
className,
children,
}: FileUploadProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp };
const reduced = useReducedMotion() ?? false;
const inputRef = React.useRef<HTMLInputElement>(null);
const dragDepth = React.useRef(0);
const [dragging, setDragging] = React.useState(false);
const maxReached = maxFiles !== undefined && files.length >= maxFiles;
const inactive = disabled || maxReached;
const addFiles = (incoming: File[]) => {
if (disabled || incoming.length === 0) return;
const rejections: FileRejection[] = [];
let slots = maxFiles === undefined ? Infinity : maxFiles - files.length;
if (!multiple) slots = Math.min(slots, 1);
const accepted: File[] = [];
for (const file of incoming) {
if (!matchesAccept(file, accept)) {
rejections.push({ file, reason: "not-accepted" });
} else if (maxSize !== undefined && file.size > maxSize) {
rejections.push({ file, reason: "too-large" });
} else if (accepted.length >= slots) {
rejections.push({ file, reason: "max-files" });
} else {
accepted.push(file);
}
}
if (rejections.length > 0) onFilesRejected?.(rejections);
if (accepted.length > 0) onFilesAdded?.(accepted);
};
const open = () => {
if (!inactive) inputRef.current?.click();
};
const hasFiles = (event: React.DragEvent) =>
Array.from(event.dataTransfer.types).includes("Files");
const dropHandlers = {
onDragEnter: (event: React.DragEvent) => {
if (inactive || !hasFiles(event)) return;
event.preventDefault();
dragDepth.current += 1;
setDragging(true);
},
onDragOver: (event: React.DragEvent) => {
if (inactive || !hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
},
onDragLeave: (event: React.DragEvent) => {
if (inactive || !hasFiles(event)) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
},
onDrop: (event: React.DragEvent) => {
if (inactive || !hasFiles(event)) return;
event.preventDefault();
dragDepth.current = 0;
setDragging(false);
addFiles(Array.from(event.dataTransfer.files));
},
};
const input = (
<input
ref={inputRef}
type="file"
aria-label={labels.input}
accept={accept}
multiple={multiple}
disabled={inactive}
tabIndex={-1}
className="sr-only"
onChange={(event) => {
addFiles(Array.from(event.currentTarget.files ?? []));
event.currentTarget.value = "";
}}
/>
);
const context: FileUploadContextValue = {
open,
disabled: inactive,
labels,
};
if (variant === "compact") {
return (
<FileUploadContext.Provider value={context}>
<div
data-slot="file-upload"
data-variant="compact"
data-dragging={dragging || undefined}
className={cn("relative flex flex-col gap-2", className)}
{...dropHandlers}
>
{input}
<FileUploadChips
files={files}
onRemove={onRemove}
onRetry={onRetry}
labels={labels}
reduced={reduced}
/>
{children}
<AnimatePresence>
{dragging ? (
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduced ? 0 : 0.16, ease: EASE_OUT }}
className="pointer-events-none absolute inset-0 z-10 grid place-items-center rounded-xl border-2 border-dashed border-ring bg-background/85 backdrop-blur-sm"
>
<motion.span
initial={reduced ? false : { y: 6, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
transition={reduced ? { duration: 0 } : SPRING_PRESS}
className="inline-flex items-center gap-2 text-sm font-medium text-foreground"
>
<UploadIcon className="size-4" />
{labels.dropOverlay}
</motion.span>
</motion.div>
) : null}
</AnimatePresence>
</div>
</FileUploadContext.Provider>
);
}
const description =
labels.description ??
(maxSize !== undefined
? labels.maxSize(formatFileSize(maxSize))
: undefined);
return (
<FileUploadContext.Provider value={context}>
<div
data-slot="file-upload"
data-variant="default"
className={cn("flex w-full flex-col gap-3", className)}
>
{input}
<motion.button
type="button"
disabled={inactive}
data-dragging={dragging || undefined}
onClick={open}
{...dropHandlers}
animate={reduced ? undefined : { scale: dragging ? 1.01 : 1 }}
whileTap={reduced || inactive ? undefined : { scale: 0.99 }}
transition={SPRING_PRESS}
className={cn(
"group relative flex w-full items-center gap-4 rounded-xl border border-dashed border-border bg-background p-5 text-left outline-none",
"transition-colors duration-200 hover:border-input hover:bg-muted/40",
"focus-visible:ring-3 focus-visible:ring-ring/40 data-dragging:border-ring data-dragging:bg-muted/60",
"disabled:pointer-events-none disabled:opacity-50"
)}
>
<motion.span
aria-hidden
animate={
reduced
? undefined
: { y: dragging ? -3 : 0, scale: dragging ? 1.06 : 1 }
}
transition={{ duration: 0.2, ease: EASE_OUT }}
className="grid size-12 shrink-0 place-items-center rounded-lg bg-muted text-foreground transition-colors duration-200 group-data-dragging:bg-primary group-data-dragging:text-primary-foreground"
>
<UploadIcon className="size-5" />
</motion.span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">
{maxReached ? labels.limitReached : labels.title}
</span>
{description && !maxReached ? (
<span className="mt-0.5 block text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
<span className="shrink-0 rounded-full border border-border px-3.5 py-1.5 text-xs font-medium text-foreground transition-colors duration-150 group-hover:bg-accent">
{labels.browse}
</span>
</motion.button>
<ul
data-slot="file-upload-list"
className="flex flex-col gap-2 empty:hidden"
>
<AnimatePresence initial={false}>
{files.map((item) => (
<FileUploadRow
key={item.id}
item={item}
onRemove={onRemove}
onRetry={onRetry}
labels={labels}
reduced={reduced}
/>
))}
</AnimatePresence>
</ul>
</div>
</FileUploadContext.Provider>
);
}
/* ----------------------------------------------------------------------------
* A queue row: icon, name, size or error, status, actions, progress.
* ------------------------------------------------------------------------- */
function StatusGlyph({
status,
label,
reduced,
}: {
status: FileUploadStatus;
label: string;
reduced: boolean;
}) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={status}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: reduced ? 0 : 0.16, ease: EASE_OUT }}
className={cn(
"grid size-7 place-items-center",
status === "done"
? "text-success"
: status === "error"
? "text-destructive"
: "text-muted-foreground"
)}
>
{status === "done" ? (
<CheckCircle2Icon aria-hidden className="size-4" />
) : status === "error" ? (
<AlertCircleIcon aria-hidden className="size-4" />
) : (
<Spinner aria-hidden role="presentation" />
)}
<span className="sr-only">{label}</span>
</motion.span>
</AnimatePresence>
);
}
function FileUploadRow({
item,
onRemove,
onRetry,
labels,
reduced,
}: {
item: FileUploadItem;
onRemove?: (item: FileUploadItem) => void;
onRetry?: (item: FileUploadItem) => void;
labels: FileUploadLabels;
reduced: boolean;
}) {
const name = itemName(item);
const Icon = iconFor(item);
const progress =
item.status === "done"
? 100
: Math.max(0, Math.min(100, item.progress ?? 0));
const determinate = item.status === "done" || item.progress !== undefined;
const showProgress = item.status !== "error";
return (
<motion.li
layout={!reduced}
data-status={item.status}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduced
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, y: -6 }
}
transition={
reduced ? { duration: 0 } : { duration: 0.22, ease: EASE_OUT }
}
className={cn(
"rounded-xl border bg-card p-3",
item.status === "error" ? "border-destructive/30" : "border-border"
)}
>
<div className="flex items-center gap-3">
{item.previewUrl ? (
<img
src={item.previewUrl}
alt=""
className="size-11 shrink-0 rounded-md bg-muted object-cover"
/>
) : (
<span
aria-hidden
className="grid size-11 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
>
<Icon className="size-5" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{name}</p>
<p
className={cn(
"mt-0.5 truncate text-xs",
item.status === "error"
? "text-destructive"
: "text-muted-foreground"
)}
>
{formatFileSize(itemSize(item))}
{item.status === "error" && item.error ? ` · ${item.error}` : null}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<StatusGlyph
status={item.status}
label={labels.status[item.status]}
reduced={reduced}
/>
{item.status === "error" && onRetry ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.retry(name)}
onClick={() => onRetry(item)}
>
<RotateCcwIcon />
</Button>
) : null}
{onRemove ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.remove(name)}
onClick={() => onRemove(item)}
>
<XIcon />
</Button>
) : null}
</div>
</div>
{showProgress ? (
<div
role="progressbar"
aria-label={labels.progress(name)}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={determinate ? Math.round(progress) : undefined}
className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted"
>
<motion.div
initial={false}
animate={{
scaleX: determinate ? progress / 100 : 0.3,
opacity: 1,
}}
transition={reduced ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"h-full origin-left rounded-full",
item.status === "done" ? "bg-success" : "bg-primary",
!determinate && !reduced && "animate-pulse"
)}
/>
</div>
) : null}
</motion.li>
);
}
/* ----------------------------------------------------------------------------
* Compact chips for the composer.
* ------------------------------------------------------------------------- */
function FileUploadChips({
files,
onRemove,
onRetry,
labels,
reduced,
}: {
files: FileUploadItem[];
onRemove?: (item: FileUploadItem) => void;
onRetry?: (item: FileUploadItem) => void;
labels: FileUploadLabels;
reduced: boolean;
}) {
return (
<ul
data-slot="file-upload-chips"
className="flex flex-wrap gap-1.5 empty:hidden"
>
<AnimatePresence initial={false}>
{files.map((item) => {
const name = itemName(item);
const Icon = iconFor(item);
const error = item.status === "error";
const progress =
item.status === "uploading" && item.progress !== undefined
? Math.max(0, Math.min(100, item.progress))
: null;
return (
<motion.li
key={item.id}
layout={!reduced}
data-status={item.status}
title={error && item.error ? item.error : name}
initial={
reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: 4 }
}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={
reduced
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, scale: 0.9, transition: { duration: 0.14 } }
}
transition={reduced ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"relative isolate flex h-8 max-w-56 items-center gap-1.5 overflow-hidden rounded-full border bg-card pr-1 pl-1 text-xs",
error
? "border-destructive/40 text-destructive"
: "border-border text-foreground"
)}
>
{progress !== null ? (
<motion.span
aria-hidden
initial={false}
animate={{ scaleX: progress / 100, opacity: 1 }}
transition={reduced ? { duration: 0 } : SPRING_LAYOUT}
className="absolute inset-0 -z-10 origin-left bg-muted"
/>
) : null}
{item.previewUrl ? (
<img
src={item.previewUrl}
alt=""
className="size-6 shrink-0 rounded-full object-cover"
/>
) : (
<span
aria-hidden
className="grid size-6 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground"
>
{item.status === "uploading" ? (
<Spinner role="presentation" className="size-3.5" />
) : error ? (
<AlertCircleIcon className="size-3.5 text-destructive" />
) : (
<Icon className="size-3.5" />
)}
</span>
)}
<span className="min-w-0 truncate font-medium">{name}</span>
<span className="sr-only">{labels.status[item.status]}</span>
{error && onRetry ? (
<Button
variant="ghost"
size="icon-xs"
aria-label={labels.retry(name)}
onClick={() => onRetry(item)}
>
<RotateCcwIcon />
</Button>
) : null}
{onRemove ? (
<Button
variant="ghost"
size="icon-xs"
aria-label={labels.remove(name)}
onClick={() => onRemove(item)}
>
<XIcon />
</Button>
) : null}
</motion.li>
);
})}
</AnimatePresence>
</ul>
);
}
/* ----------------------------------------------------------------------------
* FileUploadTrigger: the composer's attach button; opens the picker.
* ------------------------------------------------------------------------- */
export type FileUploadTriggerProps = Omit<
React.ComponentProps<typeof Button>,
"onClick"
>;
export function FileUploadTrigger({
variant = "ghost",
size = "icon-sm",
children,
disabled,
...props
}: FileUploadTriggerProps) {
const {
open,
disabled: inactive,
labels,
} = useFileUploadContext("FileUploadTrigger");
return (
<Button
type="button"
variant={variant}
size={size}
aria-label={children ? undefined : labels.attach}
disabled={disabled || inactive}
onClick={open}
{...props}
>
{children ?? <PaperclipIcon />}
</Button>
);
}Ready to try it? Three commands and the app runs against a stub model.