Checks a crop against faces, headlines and platform overlays, so a profile photo or Story doesn't cut off a face or hide text under the UI.
StopsProfile crop cut off face
"use client"
import { CropGuard, type DetectedBox } from "@/components/ui/crop-guard"
const person = (cx: number, cy: number, skin: string, shirt: string) =>
`<path d="M${cx - 150} 900 Q${cx - 140} ${cy + 110} ${cx} ${cy + 100} Q${cx + 140} ${cy + 110} ${cx + 150} 900Z" fill="${shirt}"/><circle cx="${cx}" cy="${cy}" r="60" fill="${skin}"/><circle cx="${cx - 20}" cy="${cy - 8}" r="6" fill="#2b1d16"/><circle cx="${cx + 20}" cy="${cy - 8}" r="6" fill="#2b1d16"/><path d="M${cx - 18} ${cy + 22} Q${cx} ${cy + 36} ${cx + 18} ${cy + 22}" stroke="#2b1d16" stroke-width="5" fill="none" stroke-linecap="round"/>`
// A self-contained team photo: two people and a caption, 1200 × 900.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9cc9e8"/><stop offset="1" stop-color="#f3d9b1"/></linearGradient></defs><rect width="1200" height="900" fill="url(#g)"/><circle cx="1010" cy="150" r="70" fill="#fff3c4"/><path d="M0 640 Q300 560 600 620 T1200 600 V900 H0Z" fill="#7fa77a"/>${person(620, 300, "#c98f6b", "#3d5a80")}${person(840, 330, "#8d5b3e", "#e07a5f")}<text x="630" y="642" font-family="Arial, sans-serif" font-size="40" font-weight="700" fill="#fff" text-anchor="middle">Meet the team</text></svg>`
const src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
const boxes: DetectedBox[] = [
{ kind: "face", x: 560, y: 240, w: 120, h: 120 },
{ kind: "face", x: 780, y: 270, w: 120, h: 120 },
{ kind: "text", x: 492, y: 610, w: 276, h: 42, label: "Meet the team" },
]
export function Demo() {
return (
<CropGuard
src={src}
alt="Two teammates smiling outdoors above the caption Meet the team"
imageWidth={1200}
imageHeight={900}
boxes={boxes}
defaultPreset="story"
defaultValue={{ x: 420, y: 60, w: 400, h: 711 }}
/>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/ui300 lines"use client" // Bollard UI — crop-guard · MIT licence · https://bollard-ui.vercel.app/components/crop-guard import * as React from "react" import { ScanFace, Scissors, Smartphone, Type } from "lucide-react" import { cn } from "@/lib/utils" import { checkCrop, clampRect, cropAround, DEFAULT_PRESETS, fitAspect, intersects, safeArea, type CropPreset, type CropProblem, type DetectedBox, type Rect } from "./crop-check" export { checkCrop, cropAround, DEFAULT_PRESETS, fitAspect, intersects, safeArea, type CropPreset, type CropProblem, type DetectedBox, type Rect } from "./crop-check" const pct = (r: Rect, w: number, h: number) => ({ left: `${(r.x / w) * 100}%`, top: `${(r.y / h) * 100}%`, width: `${(r.w / w) * 100}%`, height: `${(r.h / h) * 100}%` }) const count = (boxes: DetectedBox[], nouns: CropGuardProps["nouns"] & object, both: string) => { const faces = boxes.filter((b) => b.kind === "face").length const texts = boxes.length - faces const parts = [faces && (faces === 1 ? nouns.face : `${faces} ${nouns.faces}`), texts && (texts === 1 ? nouns.text : `${texts} ${nouns.texts}`)].filter( (p): p is string => Boolean(p), ) return parts.length === 2 ? both.replace("{a}", parts[0]).replace("{b}", parts[1]) : (parts[0] ?? "") } export interface CropGuardProps extends Omit<React.ComponentProps<"div">, "children" | "defaultValue"> { /** Image to crop. */ src: string /** Its natural size in pixels. Crops and boxes use these pixels. */ imageWidth: number /** Natural height of the image in pixels. */ imageHeight: number /** Accessible description of the image. */ alt?: string /** Faces and text already found, in image pixels. */ boxes?: DetectedBox[] /** Finds faces and text once the image loads (the FaceDetector API, a model, a server call). Merged with `boxes`. */ detect?: (image: HTMLImageElement) => Promise<DetectedBox[]> /** Crop shapes and safe zones to offer. */ presets?: CropPreset[] /** Controlled preset id. */ preset?: string /** Initially selected preset id when uncontrolled. */ defaultPreset?: string /** Called when the user picks another preset. */ onPresetChange?: (id: string) => void /** Controlled crop in image pixels. */ value?: Rect /** Initial crop when uncontrolled. Defaults to the largest centred crop. */ defaultValue?: Rect /** Called with the crop rectangle whenever it changes. */ onValueChange?: (crop: Rect) => void /** Words used in the warnings. */ nouns?: { face: string; faces: string; text: string; texts: string } /** Templates: `{what}`, `{ui}` and `{size}` are filled in. */ cutText?: string /** Message when a subject sits under platform UI. */ coveredText?: string /** Message when a subject is too small at preview size. */ tinyText?: string /** Message when no crop can fit every subject. */ noFitText?: string /** Text for the fit button. */ fixLabel?: string /** Caption when the preview is at the real size, and when it's scaled down. */ previewLabel?: string /** Label for the scaled-down preview. */ scaledLabel?: string /** Largest preview side, in px. */ previewMax?: number /** Joins faces and text in a warning: "{a} and {b}". */ bothText?: string /** `{ui}` when a preset has no `unsafeLabel`. */ uiLabel?: string /** Status when nothing is cut, and when there is nothing to check. */ wholeText?: string /** Message when there are no subjects to check. */ nothingText?: string /** Screen reader names of the shape picker and the crop box. */ shapesLabel?: string /** Accessible label for the crop area. */ cropAreaLabel?: string } const fix = "inline-flex h-6 items-center gap-1 rounded-md border border-amber-500/40 px-2 font-medium text-amber-900 transition-colors hover:bg-amber-500/10 dark:text-amber-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" /** * An image cropper that won't let you post a crop that slices a face or a * headline. Pluggable detector boxes are checked against the crop, the circle * mask and the platform's UI (a Story's top and bottom bars), with a one-tap * "Fit them" that grows and nudges the crop as little as possible; a live * preview shows it at the size people will actually see it. */ export function CropGuard({ src, imageWidth: W, imageHeight: H, alt = "", boxes = [], detect, presets = DEFAULT_PRESETS, preset, defaultPreset, onPresetChange, value, defaultValue, onValueChange, nouns = { face: "a face", faces: "faces", text: "text", texts: "lines of text" }, cutText = "Cuts through {what}.", coveredText = "{what} sits under {ui}.", tinyText = "Text is too small to read at {size} px.", noFitText = "They don't all fit this shape.", fixLabel = "Fit them", previewLabel = "Actual size, {size} px", scaledLabel = "Shown {size} px wide", previewMax = 96, bothText = "{a} and {b}", uiLabel = "the app's UI", wholeText = "Every face and line of text is whole.", nothingText = "Nothing detected to protect.", shapesLabel = "Crop shape", cropAreaLabel = "Crop area. Arrow keys move it, plus and minus resize it.", className, ...props }: CropGuardProps) { const [innerPreset, setInnerPreset] = React.useState(defaultPreset ?? presets[0].id) const active = presets.find((p) => p.id === (preset ?? innerPreset)) ?? presets[0] const [inner, setInner] = React.useState<Rect>(() => defaultValue ?? fitAspect(active.aspect, W, H)) const crop = value ?? inner const [found, setFound] = React.useState<DetectedBox[]>([]) const [dragging, setDragging] = React.useState(false) const frame = React.useRef<HTMLDivElement>(null) const drag = React.useRef<{ px: number; py: number; start: Rect; mode: "move" | "resize" } | null>(null) const hintId = React.useId() const all = [...boxes, ...found] const update = (next: Rect) => { const clamped = clampRect(next, W, H) if (value === undefined) setInner(clamped) onValueChange?.(clamped) } const choosePreset = (p: CropPreset) => { if (preset === undefined) setInnerPreset(p.id) onPresetChange?.(p.id) update(fitAspect(p.aspect, W, H, crop.x + crop.w / 2, crop.y + crop.h / 2)) } const resize = (start: Rect, w: number) => { const max = Math.min(W, H * active.aspect) const width = Math.min(Math.max(w, max * 0.15), max) update({ ...start, w: width, h: width / active.aspect }) } React.useEffect(() => { const image = frame.current?.querySelector("img") if (!detect || !image) return let live = true const run = () => detect(image).then((b) => live && setFound(b), () => {}) if (image.complete) run() else image.addEventListener("load", run, { once: true }) return () => { live = false } }, [detect, src]) const problems = checkCrop(crop, all, active) const of = (kind: CropProblem["kind"]) => problems.filter((p) => p.kind === kind).map((p) => p.box) const [cut, covered, tiny] = [of("cut"), of("covered"), of("tiny")] // Keep everything the crop already touches, not just the problem boxes, so fixing one never cuts another. const fitted = cut.length || covered.length ? cropAround(crop, all.filter((b) => intersects(b, crop)), active, W, H) : null const fill = (t: string, what: DetectedBox[]) => t.replace("{what}", count(what, nouns, bothText)).replace("{ui}", active.unsafeLabel ?? uiLabel).replace("{size}", String(active.shownAt)).replace(/^./, (c) => c.toUpperCase()) const lines = [ cut.length > 0 && { icon: Scissors, text: fill(cutText, cut) }, covered.length > 0 && { icon: Smartphone, text: fill(coveredText, covered) }, tiny.length > 0 && { icon: Type, text: fill(tinyText, tiny) }, ].filter((l): l is { icon: typeof Scissors; text: string } => !!l) const onPointerMove = (e: React.PointerEvent) => { const d = drag.current const rect = frame.current?.getBoundingClientRect() if (!d || !rect) return const dx = ((e.clientX - d.px) / rect.width) * W const dy = ((e.clientY - d.py) / rect.height) * H if (d.mode === "move") update({ ...d.start, x: d.start.x + dx, y: d.start.y + dy }) else resize(d.start, d.start.w + Math.max(dx, dy * active.aspect)) } const startDrag = (mode: "move" | "resize") => (e: React.PointerEvent) => { e.stopPropagation() e.currentTarget.setPointerCapture(e.pointerId) drag.current = { px: e.clientX, py: e.clientY, start: crop, mode } setDragging(true) } const endDrag = () => { drag.current = null setDragging(false) } const onKeyDown = (e: React.KeyboardEvent) => { const step = (e.shiftKey ? 0.1 : 0.02) * W const moves: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] } if (moves[e.key]) update({ ...crop, x: crop.x + moves[e.key][0], y: crop.y + moves[e.key][1] }) else if (e.key === "+" || e.key === "=") resize({ ...crop, x: crop.x - step / 2, y: crop.y - step / active.aspect / 2 }, crop.w + step) else if (e.key === "-") resize({ ...crop, x: crop.x + step / 2, y: crop.y + step / active.aspect / 2 }, crop.w - step) else return e.preventDefault() } const circle = active.shape === "circle" const safe = safeArea({ x: 0, y: 0, w: crop.w, h: crop.h }, active) // Small targets preview at their true size; big ones are scaled into a thumbnail that fits the card. const previewW = Math.min(active.shownAt, previewMax * Math.min(1, active.aspect)) const scale = previewW / crop.w const shownH = previewW / active.aspect const flagged = new Set(problems.filter((p) => p.kind !== "tiny").map((p) => p.box)) return ( <div data-slot="crop-guard" className={cn("grid w-full max-w-md gap-3", className)} {...props}> <div role="radiogroup" aria-label={shapesLabel} onKeyDown={(e) => { // Radio-group keys: arrows move the choice and the focus together, wrapping at the ends. const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key] if (!step) return e.preventDefault() const next = presets[(presets.indexOf(active) + step + presets.length) % presets.length] choosePreset(next) e.currentTarget.querySelector<HTMLButtonElement>(`[data-preset="${CSS.escape(next.id)}"]`)?.focus() }} className="flex flex-wrap gap-1 rounded-md bg-muted p-1" > {presets.map((p) => ( <button key={p.id} type="button" role="radio" data-preset={p.id} tabIndex={p.id === active.id ? 0 : -1} aria-checked={p.id === active.id} onClick={() => choosePreset(p)} className="h-7 flex-1 rounded-sm px-2 text-xs font-medium whitespace-nowrap text-muted-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-checked:bg-background aria-checked:text-foreground aria-checked:shadow-xs"> {p.label} </button> ))} </div> <div ref={frame} className="relative touch-none overflow-hidden rounded-md bg-muted select-none" style={{ aspectRatio: `${W} / ${H}` }} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}> {/* eslint-disable-next-line @next/next/no-img-element */} <img src={src} alt={alt} draggable={false} className="absolute inset-0 size-full" /> {all.map((b, i) => ( <span key={i} aria-hidden className={cn("absolute rounded-sm border", flagged.has(b) ? "border-amber-400 bg-amber-400/15" : "border-white/60 border-dashed")} style={pct(b, W, H)} /> ))} <div tabIndex={0} role="group" aria-label={cropAreaLabel} aria-describedby={hintId} onPointerDown={startDrag("move")} onKeyDown={onKeyDown} className={cn( "absolute cursor-move border-2 border-white shadow-[0_0_0_9999px_rgb(0_0_0/0.55)] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/80", !dragging && "transition-[left,top,width,height] duration-300 ease-out motion-reduce:transition-none", circle && "rounded-full", problems.some((p) => p.kind !== "tiny") && "border-amber-400", )} style={pct(crop, W, H)} > {active.unsafe && ( <span aria-hidden className="pointer-events-none absolute border border-dashed border-white/70" style={pct(safe, crop.w, crop.h)} /> )} <span aria-hidden onPointerDown={startDrag("resize")} className={cn("absolute size-4 cursor-nwse-resize rounded-sm border-2 border-white bg-black/40", circle ? "right-[12%] bottom-[12%]" : "-right-1.5 -bottom-1.5")} /> </div> </div> <div className="flex items-start gap-3"> <div id={hintId} aria-live="polite" className="grid min-w-0 flex-1 gap-1 text-xs"> {lines.length ? ( lines.map(({ icon: Icon, text }) => ( <p key={text} className="flex gap-1.5 text-amber-700 dark:text-amber-400"> <Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" /> <span>{text}</span> </p> )) ) : ( <p className="flex gap-1.5 text-muted-foreground"> <ScanFace aria-hidden className="mt-0.5 size-3.5 shrink-0" /> <span>{all.length ? wholeText : nothingText}</span> </p> )} {(cut.length > 0 || covered.length > 0) && (fitted ? ( <button type="button" onClick={() => update(fitted)} className={cn(fix, "w-fit")}>{fixLabel}</button> ) : ( <p className="text-muted-foreground">{noFitText}</p> ))} </div> <figure className="grid shrink-0 justify-items-center gap-1"> <div className={cn("relative overflow-hidden bg-muted ring-1 ring-border", circle ? "rounded-full" : "rounded-sm")} style={{ width: previewW, height: shownH }}> {/* eslint-disable-next-line @next/next/no-img-element */} <img src={src} alt="" className="absolute max-w-none" style={{ width: W * scale, height: H * scale, left: -crop.x * scale, top: -crop.y * scale }} /> </div> <figcaption className="text-[11px] text-muted-foreground">{(previewW === active.shownAt ? previewLabel : scaledLabel).replace("{size}", String(active.shownAt))}</figcaption> </figure> </div> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { CropGuard, type DetectedBox } from "@/components/ui/crop-guard"
<CropGuard
src={src}
alt="Two teammates smiling outdoors above the caption Meet the team"
imageWidth={1200}
imageHeight={900}
boxes={boxes}
defaultPreset="story"
defaultValue={{ x: 420, y: 60, w: 400, h: 711 }}
/>What it catches
- Faces or text boxes cut by the crop edge
- Content hidden by a circle mask or a platform's Story UI
- Subjects too small to recognise at thumbnail size
- A nearest fit that keeps everything in, moving the crop as little as possible
Props
| Prop | Type | Default |
|---|---|---|
src* (required)Image to crop. | string | — |
imageWidth* (required)Its natural size in pixels. Crops and boxes use these pixels. | number | — |
imageHeight* (required)Natural height of the image in pixels. | number | — |
altAccessible description of the image. | string | "" |
boxesFaces and text already found, in image pixels. | DetectedBox[] | [] |
detectFinds faces and text once the image loads (the FaceDetector API, a model, a server call). Merged with `boxes`. | (image: HTMLImageElement) => Promise<DetectedBox[]> | — |
presetsCrop shapes and safe zones to offer. | CropPreset[] | DEFAULT_PRESETS |
presetControlled preset id. | string | — |
defaultPresetInitially selected preset id when uncontrolled. | string | — |
onPresetChangeCalled when the user picks another preset. | (id: string) => void | — |
valueControlled crop in image pixels. | Rect | — |
defaultValueInitial crop when uncontrolled. Defaults to the largest centred crop. | Rect | — |
onValueChangeCalled with the crop rectangle whenever it changes. | (crop: Rect) => void | — |
nounsWords used in the warnings. | { face: string; faces: string; text: string; texts: string } | { face: "a face", faces: "faces", text: "text", texts: "l… |
cutTextTemplates: `{what}`, `{ui}` and `{size}` are filled in. | string | "Cuts through {what}." |
coveredTextMessage when a subject sits under platform UI. | string | "{what} sits under {ui}." |
tinyTextMessage when a subject is too small at preview size. | string | "Text is too small to read at {size} px." |
noFitTextMessage when no crop can fit every subject. | string | "They don't all fit this shape." |
fixLabelText for the fit button. | string | "Fit them" |
previewLabelCaption when the preview is at the real size, and when it's scaled down. | string | "Actual size, {size} px" |
scaledLabelLabel for the scaled-down preview. | string | "Shown {size} px wide" |
previewMaxLargest preview side, in px. | number | 96 |
bothTextJoins faces and text in a warning: "{a} and {b}". | string | "{a} and {b}" |
uiLabel`{ui}` when a preset has no `unsafeLabel`. | string | "the app's UI" |
wholeTextStatus when nothing is cut, and when there is nothing to check. | string | "Every face and line of text is whole." |
nothingTextMessage when there are no subjects to check. | string | "Nothing detected to protect." |
shapesLabelScreen reader names of the shape picker and the crop box. | string | "Crop shape" |
cropAreaLabelAccessible label for the crop area. | string | "Crop area. Arrow keys move it, plus and minus resize it." |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- The crop area is keyboard-operable: arrow keys move it, + and - resize
- Shape presets are a role="radiogroup" with arrow-key navigation
- The image takes alt text; problems are described in text
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Needs face and text boxes from you or a detect function; it has no built-in detector
- Safe zones are presets and may not match a platform's latest layout
- Sometimes no crop can fit everything; it says so
What it stops
Profile crop cut off face
How people describe it
Also known as
image crop · avatar upload · profile photo · instagram story · safe zone · cropper · thumbnail · dp
Field type