FileUpload
Single-file drag-and-drop + click picker with a type-aware preview, validation, progress and error states.
import { FileUpload }from "@/components/ui"
Specification
Lets a user attach one file by drag-and-drop or click. Shows accepted types and max size upfront, validates on select, and previews the file (image thumbnail or type icon + name/size). Presentational — wire the upload via onSelect and drive status/progress.
Anatomy
- Drop zone: dashed border, upload icon, click/drag instruction
- Constraints line: accepted types + max size (13px tertiary)
- Preview: image thumbnail or file-type icon + name/size
- Progress bar while uploading; remove button when idle/complete
States
- Idle: dashed border, grey icon
- Drag over: accent border + light accent background
- Selected: preview row replaces the zone
- Uploading: progress bar + percentage
- Complete: check icon; Error: red border + message
Accessibility
- Hidden but focusable <input type=file>
- Drop zone is role=button, tabindex=0, keyboard-activated (Enter/Space)
- Progress announced via aria-live
- Errors shown as text + icon, never color alone
Spec sourced from mathesis ui-component/file-upload
Props (12)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | File | null | — | The selected file (controlled). Pass null for empty. |
| onSelect | (file: File | null) => void | — | Called with a valid file, or null when removed. |
| onError | (message: string) => void | — | Called when a dropped/picked file fails validation. |
| accept | string | — | Accepted types — extensions, MIME types, or wildcards (e.g. .pdf,image/*). Shown as a constraint and enforced. |
| maxSize | number | — | Max size per file in bytes; shown upfront and enforced on select. |
| status | "idle" | "uploading" | "complete" | "error" | "idle" | Upload lifecycle, driven by the parent's upload logic. |
| progress | number | 0 | Upload progress 0–100 while status is "uploading". |
| label | string | — | Field label shown above the zone. |
| hint | string | — | Helper text below the field. |
| error | string | — | External error (e.g. server rejection); overrides internal validation error. |
| required | boolean | — | Shows the required marker. |
| disabled | boolean | — | Disables the picker and drop zone. |
Single document
Accepts PDF/Word up to 10 MB; previews the selected file.
Click to browse or drag and drop
.pdf,.doc,.docx · up to 10.0 MB
PDF or Word, up to 10 MB
Full source
The complete component, read from
src/components/ui/file-upload.tsx.file-upload.tsx
"use client";
import { useState, useRef, useEffect, useId, type DragEvent, type ChangeEvent } from "react";
import { UploadCloud, X, CheckCircle2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
formatFileSize,
fileKind,
fileIcon,
validateFile,
type FileConstraints,
} from "@/lib/upload";
import { Field } from "./field";
import { Progress } from "./progress";
/** Upload lifecycle for the selected file, controlled by the parent. */
export type UploadStatus = "idle" | "uploading" | "complete" | "error";
interface FileUploadProps {
id?: string;
label?: string;
hint?: string;
/** External error (e.g. server rejection). Overrides internal validation error. */
error?: string;
required?: boolean;
disabled?: boolean;
/** `accept` list — extensions, MIME types, or wildcards (e.g. `.pdf,image/*`). */
accept?: string;
/** Max size per file, in bytes. Shown as a constraint and enforced on select. */
maxSize?: number;
/** The currently selected file (controlled). Pass `null` for empty. */
value?: File | null;
/** Called with a valid file, or `null` when removed. */
onSelect?: (file: File | null) => void;
/** Called when a file fails validation, with the reason. */
onError?: (message: string) => void;
/** Upload lifecycle state, driven by the parent's upload logic. */
status?: UploadStatus;
/** Upload progress 0–100 (only meaningful while `status === "uploading"`). */
progress?: number;
}
/**
* FileUpload — single-file drag-and-drop + click picker with a type-aware
* preview (image thumbnail or file icon + name/size), validation, progress and
* error states. Presentational: wire the actual upload via `onSelect` and drive
* `status`/`progress` from the parent.
*/
export function FileUpload({
id,
label,
hint,
error,
required,
disabled,
accept,
maxSize,
value,
onSelect,
onError,
status = "idle",
progress = 0,
}: FileUploadProps) {
const reactId = useId();
const inputId = id ?? reactId;
const messageId = `${inputId}-message`;
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const [internalError, setInternalError] = useState<string | null>(null);
const [thumb, setThumb] = useState<string | null>(null);
const shownError = error ?? internalError ?? undefined;
const constraints: FileConstraints = { accept, maxSize };
// Build (and revoke) an object URL for image previews.
useEffect(() => {
if (value && fileKind(value.name, value.type) === "image") {
const url = URL.createObjectURL(value);
setThumb(url);
return () => URL.revokeObjectURL(url);
}
setThumb(null);
}, [value]);
const accept_file = (file: File) => {
const message = validateFile(file, constraints);
if (message) {
setInternalError(message);
onError?.(message);
return;
}
setInternalError(null);
onSelect?.(file);
};
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) accept_file(file);
e.target.value = ""; // allow re-selecting the same file
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragOver(false);
if (disabled) return;
const file = e.dataTransfer.files?.[0];
if (file) accept_file(file);
};
const remove = () => {
setInternalError(null);
onSelect?.(null);
};
const openPicker = () => !disabled && inputRef.current?.click();
return (
<Field label={label} hint={hint} error={shownError} required={required} htmlFor={inputId} messageId={messageId}>
<input
ref={inputRef}
id={inputId}
type="file"
accept={accept}
disabled={disabled}
onChange={onInputChange}
className="sr-only"
aria-describedby={messageId}
/>
{!value ? (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={label ? `Upload ${label}` : "Upload file"}
aria-disabled={disabled || undefined}
onClick={openPicker}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPicker();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!disabled) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-6 py-8 text-center transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
disabled
? "cursor-not-allowed border-border bg-bg-secondary opacity-60"
: "cursor-pointer border-border hover:border-border-hover hover:bg-bg-hover",
dragOver && !disabled && "border-accent bg-accent-light",
shownError && "border-error"
)}
>
<UploadCloud
size={24}
className={cn("transition-colors", dragOver ? "text-accent" : "text-text-tertiary")}
aria-hidden
/>
<div className="text-sm text-text">
<span className="font-medium text-accent">Click to browse</span>{" "}
<span className="text-text-secondary">or drag and drop</span>
</div>
{(accept || maxSize) && (
<p className="text-xs text-text-tertiary">
{accept ? accept : "Any file"}
{maxSize ? ` · up to ${formatFileSize(maxSize)}` : ""}
</p>
)}
</div>
) : (
<FilePreview
file={value}
thumb={thumb}
status={status}
progress={progress}
error={Boolean(shownError)}
disabled={disabled}
onRemove={remove}
/>
)}
</Field>
);
}
/** Preview row: image thumbnail or type icon + name/size, with progress/state. */
function FilePreview({
file,
thumb,
status,
progress,
error,
disabled,
onRemove,
}: {
file: File;
thumb: string | null;
status: UploadStatus;
progress: number;
error: boolean;
disabled?: boolean;
onRemove: () => void;
}) {
const kind = fileKind(file.name, file.type);
const Icon = fileIcon(kind);
return (
<div
className={cn(
"flex items-center gap-3 rounded-xl border p-3",
error ? "border-error bg-error-light" : "border-border bg-surface"
)}
>
<div className="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-bg-secondary">
{thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumb} alt="" className="size-full object-cover" />
) : (
<Icon size={20} className="text-text-secondary" aria-hidden />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-text">{file.name}</p>
{(status === "complete" || status === "idle") && !error && (
<CheckCircle2 size={15} className="shrink-0 text-accent" aria-hidden />
)}
{(status === "error" || error) && (
<AlertCircle size={15} className="shrink-0 text-error" aria-hidden />
)}
</div>
<p className="text-xs tabular-nums text-text-tertiary">
{formatFileSize(file.size)}
{status === "uploading"
? ` · ${Math.round(progress)}%`
: status === "complete"
? " · Uploaded"
: !error
? " · Ready to upload"
: ""}
</p>
{status === "uploading" && (
<div className="mt-1.5" aria-live="polite">
<Progress value={progress} />
</div>
)}
</div>
{!disabled && status !== "uploading" && (
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${file.name}`}
className="shrink-0 rounded-md p-1 text-text-tertiary transition-colors hover:bg-bg-hover hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<X size={16} aria-hidden />
</button>
)}
</div>
);
}Dependencies
To use FileUpload you also need the following. Copy these into your project alongside it.
Related components
Recommended folder structure
project structure
src/components/ui/ ├─ field.tsx ├─ file-upload.tsx └─ progress.tsx src/lib/ ├─ upload.ts └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/components/ui/field.tsx
field.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface FieldCounter {
text: string;
warn?: boolean;
}
interface FieldProps {
label?: string;
hint?: string;
error?: string;
required?: boolean;
htmlFor?: string;
counter?: FieldCounter;
messageId?: string;
children: ReactNode;
}
export function Field({
label,
hint,
error,
required,
htmlFor,
counter,
messageId,
children,
}: FieldProps) {
const message = error || hint;
return (
<div className="flex w-full flex-col gap-1.5">
{label && (
<label
htmlFor={htmlFor}
className="flex items-center gap-1 text-[13px] font-medium text-text"
>
{label}
{required && (
<span className="text-error" aria-hidden>
*
</span>
)}
</label>
)}
{children}
<div className="flex items-start justify-between gap-3">
<p
id={messageId}
className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
>
{message || "\u00A0"}
</p>
{counter && (
<span
className={cn(
"shrink-0 text-xs tabular-nums",
counter.warn ? "text-error" : "text-text-tertiary"
)}
>
{counter.text}
</span>
)}
</div>
</div>
);
}
export const controlBase = cn(
"w-full rounded-lg border text-sm bg-surface text-text transition-colors",
"placeholder:text-text-tertiary",
"focus:outline-none focus-visible:outline-none focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
"read-only:bg-bg-secondary"
);
export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
return cn(
error ? "border-error focus-visible:border-error" : "border-border",
readOnly && "read-only:bg-bg-secondary",
disabled && "text-text-tertiary"
);
}src/lib/upload.ts
upload.ts
import {
FileText,
FileSpreadsheet,
FileImage,
FileArchive,
FileVideo,
FileAudio,
FileCode,
File as FileIcon,
type LucideIcon,
} from "lucide-react";
/**
* Shared, framework-agnostic helpers for the upload components
* (FileUpload, FileUploadMultiple, PhotoUpload) and DocumentsTable.
* Pure functions — safe to call during render.
*/
/** Broad file kind used for preview + icon selection. */
export type FileKind = "image" | "document" | "spreadsheet" | "archive" | "video" | "audio" | "code" | "other";
/** Format a byte count as a human-readable size, e.g. `2.4 MB`. */
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
const value = bytes / 1024 ** i;
// No decimals for bytes; one decimal otherwise.
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
/** Lowercased extension without the dot (e.g. `pdf`), or `""` when none. */
export function fileExtension(name: string): string {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
}
const EXT_KIND: Record<string, FileKind> = {
// documents
pdf: "document", doc: "document", docx: "document", txt: "document", rtf: "document", md: "document",
// spreadsheets
xls: "spreadsheet", xlsx: "spreadsheet", csv: "spreadsheet",
// images
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image", avif: "image", heic: "image",
// archives
zip: "archive", rar: "archive", "7z": "archive", tar: "archive", gz: "archive",
// media
mp4: "video", mov: "video", webm: "video", avi: "video", mkv: "video",
mp3: "audio", wav: "audio", ogg: "audio", m4a: "audio",
// code
js: "code", ts: "code", tsx: "code", jsx: "code", json: "code", html: "code", css: "code",
};
/** Classify a file by MIME type first, falling back to its extension. */
export function fileKind(nameOrType: string, mime?: string): FileKind {
if (mime) {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
if (mime === "application/pdf") return "document";
if (mime.includes("spreadsheet") || mime.includes("excel") || mime === "text/csv") return "spreadsheet";
if (mime.includes("zip") || mime.includes("compressed") || mime.includes("tar")) return "archive";
}
return EXT_KIND[fileExtension(nameOrType)] ?? "other";
}
const KIND_ICON: Record<FileKind, LucideIcon> = {
image: FileImage,
document: FileText,
spreadsheet: FileSpreadsheet,
archive: FileArchive,
video: FileVideo,
audio: FileAudio,
code: FileCode,
other: FileIcon,
};
/** The lucide icon that represents a file kind. */
export function fileIcon(kind: FileKind): LucideIcon {
return KIND_ICON[kind];
}
export interface FileConstraints {
/** Max size per file, in bytes. */
maxSize?: number;
/**
* Accepted types, in the same syntax as the input `accept` attribute:
* extensions (".pdf"), MIME types ("image/png"), or wildcards ("image/*").
*/
accept?: string;
}
/**
* Validate a file against constraints. Returns a human-readable error string
* ("what + why"), or `null` when the file is acceptable.
*/
export function validateFile(file: File, constraints: FileConstraints = {}): string | null {
const { maxSize, accept } = constraints;
if (typeof maxSize === "number" && file.size > maxSize) {
return `File is too large — max ${formatFileSize(maxSize)}.`;
}
if (accept && !matchesAccept(file, accept)) {
return `File type not allowed — accepted: ${accept}.`;
}
return null;
}
/** Whether a file satisfies an `accept` list (extensions, MIME, wildcards). */
export function matchesAccept(file: File, accept: string): boolean {
const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
if (patterns.length === 0) return true;
const type = file.type.toLowerCase();
const ext = `.${fileExtension(file.name)}`;
return patterns.some((p) => {
if (p.startsWith(".")) return ext === p;
if (p.endsWith("/*")) return type.startsWith(p.slice(0, -1)); // e.g. "image/"
return type === p;
});
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}