PhotoUpload

Image-only picker with a live thumbnail preview in a square, circular avatar, or wide cover frame, and an optional crop step.

import { PhotoUpload }from "@/components/ui"

Specification

Uploads a single image with an immediate preview. Use the avatar variant for profile photos, square for thumbnails, cover for banners. Validates image type + size, offers an optional crop step, and shows an uploading overlay.

Anatomy

  • Preview frame (square / circular / cover) with the image or a placeholder icon
  • Hover overlay with a camera icon to replace
  • Upload / Replace + Remove buttons and a constraints line
  • Optional crop modal (canvas cropper) shown after selection when crop is enabled

States

  • Empty (placeholder icon), drag over (accent), preview (image fills frame)
  • Cropping: crop modal open (when crop enabled)
  • Uploading: spinner overlay; error: red frame + message

Accessibility

  • Hidden <input type=file accept=image/*>
  • Frame is role=button, tabindex=0, keyboard-activated
  • Crop step is pointer + keyboard operable (see ImageCropModal)
  • Uploading state announced via aria-live

Spec sourced from mathesis ui-component/photo-upload

Props (16)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
valueFile | null—Selected image file (controlled), or null.
previewUrlstring—Existing image URL to show when there is no freshly-selected file (e.g. a saved avatar).
variant"square" | "avatar" | "cover""square"Preview frame shape.
onSelect(file: File | null) => void—Called with a valid image (cropped, if crop is enabled), or null when removed.
onError(message: string) => void—Called when a file fails image/size validation.
acceptstring"image/*"Accepted image types.
maxSizenumber—Max size in bytes.
cropbooleanfalseEnable an optional crop step after selecting an image; onSelect then receives the cropped image.
cropAspectnumber—Override the crop aspect (width / height). Defaults from variant (avatar/square = 1, cover = 3).
cropMinWidthnumber—Minimum cropped width in source pixels; blocks tiny crops.
status"idle" | "uploading" | "complete" | "error""idle"Upload lifecycle; shows an overlay while uploading.
progressnumber0Upload progress 0–100.
labelstring—Field label.
hintstring—Helper text below the field.
errorstring—External error; overrides internal validation error.
disabledboolean—Disables the picker.

Avatar & cover, with crop

Circular avatar and wide cover variants. Selecting an image opens the optional cropper.

PNG, JPG, GIF, WEBP · up to 5.0 MB

 

Click or drag an image

PNG, JPG, GIF, WEBP · up to 5.0 MB

 

Full source
The complete component, read from src/components/ui/photo-upload.tsx.
photo-upload.tsx
"use client";

import { useState, useRef, useEffect, useId, type DragEvent, type ChangeEvent } from "react";
import { ImagePlus, Camera, X, Loader2, CheckCircle2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatFileSize, validateFile, matchesAccept, type FileConstraints } from "@/lib/upload";
import type { UploadStatus } from "./file-upload";
import { Field } from "./field";
import { ImageCropModal } from "./image-crop-modal";

/** Preview frame shape. */
export type PhotoVariant = "square" | "avatar" | "cover";

interface PhotoUploadProps {
  id?: string;
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  disabled?: boolean;
  /** Frame shape: square thumbnail, circular avatar, or wide cover banner. */
  variant?: PhotoVariant;
  /** Accepted image types. Defaults to `image/*`. */
  accept?: string;
  /** Max size in bytes. */
  maxSize?: number;
  /** Selected image file (controlled), or `null`. */
  value?: File | null;
  /** Existing image URL to show when there is no freshly-selected file (e.g. a saved avatar). */
  previewUrl?: string;
  /** Called with a valid image, or `null` when removed. */
  onSelect?: (file: File | null) => void;
  /** Called when a file fails validation. */
  onError?: (message: string) => void;
  status?: UploadStatus;
  progress?: number;
  /**
   * Enable an optional crop step: after selecting a valid image, a cropper
   * opens and `onSelect` receives the cropped image. The crop is locked to the
   * variant's aspect by default (avatar/square = 1:1, cover = 3:1).
   */
  crop?: boolean;
  /** Override the crop aspect ratio (width / height). Defaults from `variant`. */
  cropAspect?: number;
  /** Minimum cropped width in source pixels (blocks tiny crops). */
  cropMinWidth?: number;
}

const FRAME: Record<PhotoVariant, string> = {
  square: "size-32 rounded-xl",
  avatar: "size-28 rounded-full",
  cover: "h-40 w-full rounded-xl",
};

const VARIANT_ASPECT: Record<PhotoVariant, number> = {
  square: 1,
  avatar: 1,
  cover: 3,
};

/**
 * PhotoUpload — image-only picker with a live thumbnail preview. Supports a
 * square, circular avatar, or wide cover frame; drag-and-drop and click; an
 * optional crop step; and an uploading overlay. Presentational: wire uploads
 * via `onSelect` + `status`.
 */
export function PhotoUpload({
  id,
  label,
  hint,
  error,
  required,
  disabled,
  variant = "square",
  accept = "image/*",
  maxSize,
  value,
  previewUrl,
  onSelect,
  onError,
  status = "idle",
  progress = 0,
  crop = false,
  cropAspect,
  cropMinWidth,
}: PhotoUploadProps) {
  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 [objectUrl, setObjectUrl] = useState<string | null>(null);
  const [cropSrc, setCropSrc] = useState<string | null>(null);

  const shownError = error ?? internalError ?? undefined;
  const constraints: FileConstraints = { accept, maxSize };
  const preview = objectUrl ?? (value ? null : previewUrl ?? null);
  const effectiveAspect = cropAspect ?? VARIANT_ASPECT[variant];

  useEffect(() => {
    if (value) {
      const url = URL.createObjectURL(value);
      setObjectUrl(url);
      return () => URL.revokeObjectURL(url);
    }
    setObjectUrl(null);
  }, [value]);

  const accept_file = (file: File) => {
    // Guard against non-images even if the OS dialog allowed them.
    if (!matchesAccept(file, accept)) {
      const msg = "That file isn’t an image — choose a PNG, JPG, or WEBP.";
      setInternalError(msg);
      onError?.(msg);
      return;
    }
    const message = validateFile(file, constraints);
    if (message) {
      setInternalError(message);
      onError?.(message);
      return;
    }
    setInternalError(null);
    if (crop) {
      // Open the cropper; the cropped file is committed on confirm.
      setCropSrc(URL.createObjectURL(file));
    } else {
      onSelect?.(file);
    }
  };

  const closeCrop = () => {
    if (cropSrc) URL.revokeObjectURL(cropSrc);
    setCropSrc(null);
  };

  const onCropConfirm = (file: File) => {
    onSelect?.(file);
    closeCrop();
  };

  const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) accept_file(file);
    e.target.value = "";
  };

  const onDrop = (e: DragEvent) => {
    e.preventDefault();
    setDragOver(false);
    if (disabled) return;
    const file = e.dataTransfer.files?.[0];
    if (file) accept_file(file);
  };

  const openPicker = () => !disabled && status !== "uploading" && inputRef.current?.click();
  const uploading = status === "uploading";

  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}
      />

      <div className={cn("flex items-start gap-4", variant === "cover" && "flex-col")}>
        <div
          role="button"
          tabIndex={disabled ? -1 : 0}
          aria-label={label ? `Upload ${label}` : "Upload photo"}
          aria-disabled={disabled || undefined}
          onClick={openPicker}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === " ") {
              e.preventDefault();
              openPicker();
            }
          }}
          onDragOver={(e) => {
            e.preventDefault();
            if (!disabled && !uploading) setDragOver(true);
          }}
          onDragLeave={() => setDragOver(false)}
          onDrop={onDrop}
          className={cn(
            "group relative flex shrink-0 items-center justify-center overflow-hidden border border-dashed bg-bg-secondary transition-colors",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
            FRAME[variant],
            disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer hover:border-border-hover",
            dragOver && !disabled && "border-accent bg-accent-light",
            shownError && "border-error",
            preview && "border-solid"
          )}
        >
          {preview ? (
            <>
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img src={preview} alt="" className="size-full object-cover" />
              {!disabled && !uploading && (
                <div className="absolute inset-0 flex items-center justify-center bg-overlay opacity-0 transition-opacity group-hover:opacity-100">
                  <Camera size={20} className="text-on-dark" aria-hidden />
                </div>
              )}
            </>
          ) : (
            <div className="flex flex-col items-center gap-1 px-2 text-center">
              <ImagePlus size={22} className={cn(dragOver ? "text-accent" : "text-text-tertiary")} aria-hidden />
              {variant === "cover" && <span className="text-xs text-text-secondary">Click or drag an image</span>}
            </div>
          )}

          {uploading && (
            <div className="absolute inset-0 flex items-center justify-center bg-overlay" aria-live="polite">
              <Loader2 size={20} className="animate-spin text-on-dark" aria-hidden />
              <span className="sr-only">Uploading {Math.round(progress)}%</span>
            </div>
          )}
        </div>

        <div className="min-w-0 space-y-1.5 pt-1">
          <div className="flex flex-wrap gap-2">
            <button
              type="button"
              onClick={openPicker}
              disabled={disabled || uploading}
              className="rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text transition-colors hover:bg-bg-hover disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
            >
              {preview ? "Replace" : "Upload"}
            </button>
            {preview && !disabled && !uploading && (
              <button
                type="button"
                onClick={() => {
                  setInternalError(null);
                  onSelect?.(null);
                }}
                className="inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-sm text-text-secondary transition-colors hover:bg-bg-hover hover:text-error focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
              >
                <X size={14} aria-hidden />
                Remove
              </button>
            )}
          </div>
          <p className="text-xs text-text-tertiary">
            {accept === "image/*" ? "PNG, JPG, GIF, WEBP" : accept}
            {maxSize ? ` · up to ${formatFileSize(maxSize)}` : ""}
          </p>
          {value && <p className="truncate text-xs text-text-secondary">{value.name}</p>}
          {preview && !uploading && !shownError && (
            <p className="flex items-center gap-1 text-xs font-medium text-accent">
              <CheckCircle2 size={13} aria-hidden />
              {status === "complete" ? "Uploaded" : value ? "Added" : "Current photo"}
            </p>
          )}
        </div>
      </div>

      {crop && cropSrc && (
        <ImageCropModal
          open
          imageSrc={cropSrc}
          aspect={effectiveAspect}
          circular={variant === "avatar"}
          minWidth={cropMinWidth}
          outputName={value?.name ?? "cropped.png"}
          onConfirm={onCropConfirm}
          onCancel={closeCrop}
        />
      )}
    </Field>
  );
}

Dependencies

To use PhotoUpload you also need the following. Copy these into your project alongside it.

Related components

Recommended folder structure

project structure
src/components/ui/
  ├─ field.tsx
  ├─ image-crop-modal.tsx
  └─ photo-upload.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 };
}