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)
| Prop | Type | Default | Description |
|---|---|---|---|
| value | File | null | — | Selected image file (controlled), or null. |
| previewUrl | string | — | 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. |
| accept | string | "image/*" | Accepted image types. |
| maxSize | number | — | Max size in bytes. |
| crop | boolean | false | Enable an optional crop step after selecting an image; onSelect then receives the cropped image. |
| cropAspect | number | — | Override the crop aspect (width / height). Defaults from variant (avatar/square = 1, cover = 3). |
| cropMinWidth | number | — | Minimum cropped width in source pixels; blocks tiny crops. |
| status | "idle" | "uploading" | "complete" | "error" | "idle" | Upload lifecycle; shows an overlay while uploading. |
| progress | number | 0 | Upload progress 0–100. |
| label | string | — | Field label. |
| hint | string | — | Helper text below the field. |
| error | string | — | External error; overrides internal validation error. |
| disabled | boolean | — | 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
PNG, JPG, GIF, WEBP · up to 5.0 MB
Full source
src/components/ui/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
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
"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
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
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 };
}