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.
PropTypeDefaultDescription
valueFile | 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.
acceptstring—Accepted types — extensions, MIME types, or wildcards (e.g. .pdf,image/*). Shown as a constraint and enforced.
maxSizenumber—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.
progressnumber0Upload progress 0–100 while status is "uploading".
labelstring—Field label shown above the zone.
hintstring—Helper text below the field.
errorstring—External error (e.g. server rejection); overrides internal validation error.
requiredboolean—Shows the required marker.
disabledboolean—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 };
}