ImageCropModal
An accessible, aspect-aware image cropper: pointer + keyboard, optional circular preview, returns the cropped image as a File.
import { ImageCropModal }from "@/components/ui"
Specification
Crops an image before use. Drag inside the box to reposition and drag the handle to resize; the crop can be locked to an aspect ratio and previewed as a circle. Used by PhotoUpload's optional crop step, but standalone too. Returns the cropped image as a File.
Anatomy
- Dialog with title + short instructions
- Canvas: image with a dimmed overlay, crop border, rule-of-thirds grid, and a resize handle
- Live dimension/validity readout; Cancel / Use image actions
States
- Idle (crop centered), dragging (move), resizing (handle)
- Invalid: crop below minWidth — confirm disabled, reason shown
Accessibility
- Pointer Events unify mouse + touch; the canvas is a keyboard-operable slider (arrows move, Alt+arrows resize)
- The resize handle is a large, visible target (Fitts's Law)
- Dimensions + validity announced via aria-live with an icon + text (not color alone)
Spec sourced from mathesis ui-component/photo-upload
Props (9)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Whether the modal is open. |
| imageSrc* | string | — | URL/object URL of the image to crop (caller owns revoking object URLs). |
| aspect | number | — | Lock the crop to width / height (e.g. 1 for square, 16/9). Omit for free-form. |
| circular | boolean | — | Preview the crop as a circle (avatars). Visual only. |
| minWidth | number | — | Minimum exported width in source pixels; smaller crops are blocked. |
| outputType | string | "image/png" | Output MIME type. |
| outputName | string | "cropped.png" | Output file name. |
| onConfirm* | (file: File) => void | — | Called with the cropped image as a File. |
| onCancel* | () => void | — | Called when the user cancels or dismisses the modal. |
Crop to a circle (1:1)
Opens a cropper locked to 1:1 with a circular preview; returns a File.
Full source
The complete component, read from
src/components/ui/image-crop-modal.tsx.image-crop-modal.tsx
"use client";
import { useRef, useState, useEffect, useCallback, type PointerEvent, type KeyboardEvent } from "react";
import { Check, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog } from "./dialog";
import { Button } from "./button";
/** A crop rectangle in canvas pixels. */
interface CropBox {
x: number;
y: number;
w: number;
h: number;
}
export interface ImageCropModalProps {
open: boolean;
/** Object URL / URL of the image to crop. The caller owns revoking object URLs. */
imageSrc: string;
/**
* Fixed aspect ratio (width / height) the crop is locked to — e.g. 1 for a
* square/avatar, 16/9 for a cover. Omit for a free-form crop.
*/
aspect?: number;
/** Whether to preview the crop as a circle (avatars). Visual only. */
circular?: boolean;
/** Minimum exported width in source pixels; smaller crops are blocked. */
minWidth?: number;
/** Output MIME type. Defaults to image/png (lossless). */
outputType?: string;
/** Output file name. Defaults to "cropped.png". */
outputName?: string;
/** Called with the cropped image as a File when the user confirms. */
onConfirm: (file: File) => void;
onCancel: () => void;
}
const CANVAS_MAX_W = 560;
const HANDLE = 14; // px — Fitts's Law: a comfortably large grab target
const MIN_BOX = 48; // px on canvas
const KEY_STEP = 4; // px per arrow press (×5 with Shift)
/**
* ImageCropModal — an accessible, aspect-aware image cropper. Drag inside the
* box to reposition, drag the handle (or use the keyboard) to resize; the crop
* can be locked to an aspect ratio and previewed as a circle. Confirms with the
* cropped image as a File.
*
* Accessibility & UX (grounded in the Mathesis UX books):
* - Pointer Events unify mouse + touch; the box is keyboard-operable
* (arrows move, Shift+arrows resize) so it isn't gesture-only (Fitts's Law,
* mobile-patterns).
* - The resize handle is a large, visible target (Fitts's Law).
* - Validity + dimensions are announced via an aria-live region with an icon +
* text, never color alone (color-perception / accessibility).
* - Only essential controls are shown (progressive disclosure).
*/
export function ImageCropModal({
open,
imageSrc,
aspect,
circular,
minWidth = 0,
outputType = "image/png",
outputName = "cropped.png",
onConfirm,
onCancel,
}: ImageCropModalProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const [crop, setCrop] = useState<CropBox>({ x: 0, y: 0, w: 0, h: 0 });
const [scale, setScale] = useState(1); // source px per canvas px
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const drag = useRef<{ mode: "move" | "resize"; px: number; py: number; box: CropBox } | null>(null);
const outWidth = Math.round(crop.w * scale);
const outHeight = Math.round(crop.h * scale);
const tooSmall = minWidth > 0 && outWidth < minWidth;
// Clamp a box to canvas bounds while (optionally) preserving aspect.
const clampBox = useCallback(
(box: CropBox, cw: number, ch: number): CropBox => {
let { x, y, w, h } = box;
w = Math.max(MIN_BOX, w);
h = aspect ? w / aspect : Math.max(MIN_BOX, h);
if (w > cw) { w = cw; h = aspect ? w / aspect : h; }
if (h > ch) { h = ch; w = aspect ? h * aspect : w; }
x = Math.max(0, Math.min(x, cw - w));
y = Math.max(0, Math.min(y, ch - h));
return { x, y, w, h };
},
[aspect]
);
// Load image + set initial centered crop.
useEffect(() => {
if (!open || !imageSrc) return;
const img = new window.Image();
img.onload = () => {
imgRef.current = img;
const cw = Math.min(img.naturalWidth, CANVAS_MAX_W);
const s = img.naturalWidth / cw;
const ch = Math.round(img.naturalHeight / s);
setScale(s);
setCanvasSize({ w: cw, h: ch });
// Largest aspect-correct box that fits, centered.
let w = cw;
let h = aspect ? w / aspect : Math.round(ch * 0.8);
if (h > ch) { h = ch; w = aspect ? h * aspect : w; }
setCrop({ x: Math.round((cw - w) / 2), y: Math.round((ch - h) / 2), w, h });
};
img.src = imageSrc;
}, [open, imageSrc, aspect]);
// Draw the canvas: image, dim overlay, crop border/mask, grid, handle.
useEffect(() => {
const canvas = canvasRef.current;
const img = imgRef.current;
if (!canvas || !img || crop.w === 0) return;
const { w: cw, h: ch } = canvasSize;
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(img, 0, 0, cw, ch);
// Dim outside the crop.
ctx.fillStyle = "rgba(0,0,0,0.55)";
ctx.fillRect(0, 0, cw, crop.y);
ctx.fillRect(0, crop.y + crop.h, cw, ch - crop.y - crop.h);
ctx.fillRect(0, crop.y, crop.x, crop.h);
ctx.fillRect(crop.x + crop.w, crop.y, cw - crop.x - crop.w, crop.h);
// Circular mask preview: clear a circle within the box.
if (circular) {
ctx.save();
ctx.beginPath();
const r = Math.min(crop.w, crop.h) / 2;
ctx.arc(crop.x + crop.w / 2, crop.y + crop.h / 2, r, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
// Crop border.
ctx.strokeStyle = "#fff";
ctx.lineWidth = 2;
ctx.strokeRect(crop.x + 1, crop.y + 1, crop.w - 2, crop.h - 2);
// Rule-of-thirds grid.
ctx.strokeStyle = "rgba(255,255,255,0.35)";
ctx.lineWidth = 1;
for (let i = 1; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(crop.x + (crop.w / 3) * i, crop.y);
ctx.lineTo(crop.x + (crop.w / 3) * i, crop.y + crop.h);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(crop.x, crop.y + (crop.h / 3) * i);
ctx.lineTo(crop.x + crop.w, crop.y + (crop.h / 3) * i);
ctx.stroke();
}
// Resize handle (bottom-right), large for easy grabbing.
ctx.fillStyle = "#fff";
ctx.fillRect(crop.x + crop.w - HANDLE, crop.y + crop.h - HANDLE, HANDLE, HANDLE);
ctx.strokeStyle = "rgba(0,0,0,0.25)";
ctx.lineWidth = 1;
ctx.strokeRect(crop.x + crop.w - HANDLE, crop.y + crop.h - HANDLE, HANDLE, HANDLE);
}, [crop, canvasSize, circular]);
const toCanvas = (e: PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
return {
x: ((e.clientX - rect.left) / rect.width) * canvas.width,
y: ((e.clientY - rect.top) / rect.height) * canvas.height,
};
};
const onPointerDown = (e: PointerEvent<HTMLCanvasElement>) => {
const p = toCanvas(e);
const onHandle =
p.x >= crop.x + crop.w - HANDLE - 4 &&
p.x <= crop.x + crop.w + 4 &&
p.y >= crop.y + crop.h - HANDLE - 4 &&
p.y <= crop.y + crop.h + 4;
const inside = p.x >= crop.x && p.x <= crop.x + crop.w && p.y >= crop.y && p.y <= crop.y + crop.h;
if (!onHandle && !inside) return;
canvasRef.current?.setPointerCapture(e.pointerId);
drag.current = { mode: onHandle ? "resize" : "move", px: p.x, py: p.y, box: crop };
};
const onPointerMove = (e: PointerEvent<HTMLCanvasElement>) => {
if (!drag.current) return;
const p = toCanvas(e);
const { mode, px, py, box } = drag.current;
const { w: cw, h: ch } = canvasSize;
if (mode === "resize") {
const nextW = box.w + (p.x - px);
setCrop(clampBox({ ...box, w: nextW, h: aspect ? nextW / aspect : box.h + (p.y - py) }, cw, ch));
} else {
setCrop(clampBox({ ...box, x: box.x + (p.x - px), y: box.y + (p.y - py) }, cw, ch));
}
};
const endDrag = (e: PointerEvent<HTMLCanvasElement>) => {
drag.current = null;
canvasRef.current?.releasePointerCapture?.(e.pointerId);
};
const onKeyDown = (e: KeyboardEvent<HTMLCanvasElement>) => {
const { w: cw, h: ch } = canvasSize;
const step = e.shiftKey ? KEY_STEP * 5 : KEY_STEP;
const resize = e.altKey; // Alt+arrows resize, arrows move
let next: CropBox | null = null;
if (e.key === "ArrowLeft") next = resize ? { ...crop, w: crop.w - step } : { ...crop, x: crop.x - step };
else if (e.key === "ArrowRight") next = resize ? { ...crop, w: crop.w + step } : { ...crop, x: crop.x + step };
else if (e.key === "ArrowUp") next = resize ? { ...crop, w: crop.w - step } : { ...crop, y: crop.y - step };
else if (e.key === "ArrowDown") next = resize ? { ...crop, w: crop.w + step } : { ...crop, y: crop.y + step };
if (!next) return;
e.preventDefault();
if (resize && aspect) next.h = next.w / aspect;
setCrop(clampBox(next, cw, ch));
};
const confirm = () => {
const img = imgRef.current;
if (!img || tooSmall) return;
const sx = crop.x * scale;
const sy = crop.y * scale;
const sw = crop.w * scale;
const sh = crop.h * scale;
const out = document.createElement("canvas");
out.width = Math.round(sw);
out.height = Math.round(sh);
const ctx = out.getContext("2d");
if (!ctx) return;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, out.width, out.height);
out.toBlob(
(blob) => {
if (blob) onConfirm(new File([blob], outputName, { type: outputType }));
},
outputType,
0.95
);
};
return (
<Dialog
open={open}
onClose={onCancel}
size="lg"
title="Crop image"
description={
aspect
? "Drag to reposition, drag the handle to resize. The crop keeps a fixed shape."
: "Drag to reposition, drag the handle to resize."
}
footer={
<>
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={confirm} disabled={tooSmall}>
Use image
</Button>
</>
}
>
<div className="space-y-3">
<div className="flex items-center justify-center overflow-auto rounded-lg bg-surface-dark p-2" style={{ maxHeight: 420 }}>
<canvas
ref={canvasRef}
tabIndex={0}
role="slider"
aria-label="Crop area — arrow keys move, Alt+arrows resize"
aria-valuetext={`${outWidth} by ${outHeight} pixels`}
className="max-w-full touch-none rounded outline-none focus-visible:ring-2 focus-visible:ring-accent"
style={{ cursor: drag.current?.mode === "resize" ? "nwse-resize" : "grab" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
/>
</div>
<p
aria-live="polite"
className={cn(
"flex items-center gap-1.5 text-xs font-medium tabular-nums",
tooSmall ? "text-error" : "text-text-secondary"
)}
>
{tooSmall ? (
<>
<AlertTriangle size={13} aria-hidden />
Crop is {outWidth}px wide — needs at least {minWidth}px.
</>
) : (
<>
<Check size={13} className="text-accent" aria-hidden />
{outWidth} × {outHeight} px
</>
)}
</p>
</div>
</Dialog>
);
}Dependencies
To use ImageCropModal you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ ├─ button.tsx ├─ dialog.tsx └─ image-crop-modal.tsx src/hooks/ ├─ use-focus-trap.ts ├─ use-mounted.ts └─ use-popover-position.ts src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/hooks/use-focus-trap.ts
use-focus-trap.ts
"use client";
import { useEffect, type RefObject } from "react";
const FOCUSABLE =
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* useFocusTrap — while `active`, traps Tab focus within `ref`, focuses the
* first focusable element on open, restores focus to the previously focused
* element on close, and calls `onEscape` when Escape is pressed. Also locks
* body scroll while active.
*/
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
active: boolean,
onEscape?: () => void
) {
useEffect(() => {
if (!active) return;
const node = ref.current;
if (!node) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
// Focus the first focusable element (or the container).
const focusables = () =>
Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null
);
const first = focusables()[0];
(first ?? node).focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape?.();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) {
e.preventDefault();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener("keydown", onKeyDown);
// Lock body scroll.
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
previouslyFocused?.focus?.();
};
}, [active, ref, onEscape]);
}src/hooks/use-mounted.ts
use-mounted.ts
"use client";
import { useState, useEffect } from "react";
/**
* Returns true once the component has mounted on the client.
* Useful for portals and anything that needs `document` to exist.
*/
export function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";
import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";
interface PopoverPositionOptions {
align?: "start" | "center" | "end";
gap?: number;
viewportPadding?: number;
preferredHeight?: number;
matchWidth?: boolean;
minWidth?: number;
}
interface PopoverPosition {
placement: "top" | "bottom";
style: CSSProperties;
}
/**
* Compute a fixed-position popover style anchored to a ref element.
* Opens downward by default; flips upward when there isn't room below
* AND there's more room above. Clamps horizontally so nothing renders
* past the viewport edge. Re-measures on scroll/resize while open.
*/
export function usePopoverPosition(
anchorRef: RefObject<HTMLElement | null>,
open: boolean,
options: PopoverPositionOptions = {}
): PopoverPosition | null {
const {
align = "start",
gap = 4,
viewportPadding = 8,
preferredHeight = 320,
matchWidth = false,
minWidth,
} = options;
const [position, setPosition] = useState<PopoverPosition | null>(null);
const measure = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceBelow = vh - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const placement =
spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
? "top"
: "bottom";
const maxHeight = Math.max(
120,
Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
);
const width = matchWidth ? rect.width : minWidth;
let left = align === "end" && width ? rect.right - width : rect.left;
if (align === "center" && width)
left = rect.left + rect.width / 2 - width / 2;
left = width
? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
: Math.min(Math.max(left, viewportPadding), vw - viewportPadding);
setPosition({
placement,
style: {
position: "fixed",
top: placement === "top" ? undefined : rect.bottom + gap,
bottom: placement === "top" ? vh - rect.top + gap : undefined,
left,
width: matchWidth ? rect.width : undefined,
minWidth: !matchWidth ? minWidth : undefined,
maxHeight,
},
});
}, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [open, measure]);
return position;
}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 };
}