Skip to contentVibraUI
Media

Image cropper

A picture under a frame to drag, zoom, nudge and turn — by pointer, wheel, pinch or keys — with aspect presets, a round mask, and cropImage() to cut the frame's area to a Blob.

The picture sits under a frame that keeps the chosen aspect, on the dark planes as media is, with the outside of the frame under the overlay — the backdrop token, which darkens in either theme — and a thirds grid while it is dragged (it fades in and out, and simply appears under reduced motion). At zoom 1 the picture just covers the frame; it can never be moved so far that the frame shows past its edge, and turning it a quarter or changing the aspect pulls it back inside. Every action has a key: with the crop area focused — a group named Crop area, whose description says how to work it — the arrow keys move the picture 10px (50 with Shift), and + and − zoom by a tenth between minZoom and maxZoom. The arrows are physical on purpose: the picture is not a line of text, so ArrowRight moves it right in a right-to-left page too. The pointer drags it, the wheel zooms at the pointer — listened to natively, so the page does not scroll from under it — and two fingers pinch. A zoom slider and Rotate sit under it, and aspects adds a segmented control of the shapes the reader may pick. onCropChange hands back the source pixels the frame holds, measured in the picture as turned, after every change; cropImage(src, area, { rotation, width, height }) draws the turned source on a canvas and resolves with a Blob, WebP by default (a browser that cannot encode WebP hands back a PNG, and the Blob's type says which). Nothing leaves the browser to be cropped: send the Blob where your app keeps pictures, or show it from URL.createObjectURL. A picture from another server needs CORS headers or the canvas refuses to export it. While the picture loads the stage says so; a picture that cannot be loaded — a missing file, a format the browser cannot decode — says that in the stage with the controls off, instead of leaving an empty frame. In a page where cropping is occasional, load the cropper with a dynamic import when the reader first opens it.

Install

npx shadcn@latest add @vibra/image-cropper

Needs the @vibra registry in your components.json — set it up once.

Examples

Round, for an avatar

Props

PropTypeDefaultDescription
srcstring—The picture — a URL, or an object URL for a file the reader picked.
altstring—What the picture is.
aspectnumber1Width over height of the frame; with aspects, the one chosen first.
aspects{ label: string; value: number }[]—Shapes the reader may pick between, drawn as a segmented control named Aspect ratio.
shape"rect" | "round""rect"A round frame for an avatar; the crop is still the square around it.
zoomnumber—Controls the zoom; pair it with onZoomChange.
defaultZoomnumberminZoomWhere the zoom starts when it is not controlled.
onZoomChange(zoom: number) => void—Hears every zoom the reader asks for, rounded to hundredths.
minZoomnumber11 just covers the frame.
maxZoomnumber4The furthest in the reader can zoom.
rotation0 | 90 | 180 | 270—Controls the turn; pair it with onRotationChange.
onRotationChange(rotation: Rotation) => void—Hears each quarter turn Rotate asks for.
onCropChange(area: CropArea) => void—The source pixels the frame holds — { x, y, width, height } in the turned picture — once the picture is in and after every change.
cropImage()(src, area, { rotation?, width, height, type?, quality? }) => Promise<Blob>—Cuts the area out of the picture, turned, scaled to width × height, and encodes it: image/webp at 0.92 unless told otherwise.
cropAreaFor()(state: CropState, image: Size, frame: Size) => CropArea—The crop math the component runs, for a server or a test that wants the area without the component.
clampOffset()(state: CropState, image: Size, frame: Size) => CropState—The state with the picture pulled back inside the frame's reach.
rotatedSize()(image: Size, rotation: Rotation) => Size—A picture's size once turned: a quarter turn swaps its sides.

Dependencies

Source

components/ui/image-cropper/index.tsx
"use client"

import * as React from "react"
import { RotateCwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { Slider } from "@/components/ui/slider"

import { clampOffset, cropAreaFor, cropScale, zoomAround, type CropArea, type CropState, type Rotation } from "./crop"
import { CropFrame, CropStatus } from "./frame"

export { clampOffset, cropAreaFor, cropImage, cropScale, rotatedSize, zoomAround } from "./crop"
export type { CropArea, CropImageOptions, CropState, Rotation } from "./crop"

export type ImageCropperProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  /** The picture to crop — a URL, or an object URL for a file the reader picked. */
  src: string
  alt: string
  /** Width over height of the frame. */
  aspect?: number
  /** Shapes the reader may pick between, as a segmented control; the first is chosen unless `aspect` names another. */
  aspects?: { label: string; value: number }[]
  shape?: "rect" | "round"
  zoom?: number
  defaultZoom?: number
  onZoomChange?: (zoom: number) => void
  minZoom?: number
  maxZoom?: number
  rotation?: Rotation
  onRotationChange?: (rotation: Rotation) => void
  /** The source pixels the frame holds, after every change — hand it to `cropImage`. */
  onCropChange?: (area: CropArea) => void
}

type Size = { width: number; height: number }
type Point = { x: number; y: number }

/** The room round the frame inside the stage, so the dimmed picture shows what the crop cuts away. */
const MARGIN = 24
const STEP = 10
const LEAP = 50
const ZOOM_STEP = 0.1
const ORIGIN: Point = { x: 0, y: 0 }

/** The largest frame of the aspect that fits the stage less its margin. */
function frameIn(stage: Size, aspect: number): Size | null {
  const room = { width: stage.width - MARGIN * 2, height: stage.height - MARGIN * 2 }
  if (room.width <= 0 || room.height <= 0) return null
  return room.width / room.height > aspect ? { width: room.height * aspect, height: room.height } : { width: room.width, height: room.width / aspect }
}

const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y)

/**
 * A picture under a frame: drag it, zoom it with the wheel, a pinch, the
 * slider or + and −, nudge it with the arrow keys, turn it a quarter at a
 * time. The outside of the frame is dimmed, and a thirds grid shows while it
 * is dragged. It reports the source pixels the frame holds; `cropImage` cuts
 * them out.
 */
function ImageCropper({
  src,
  alt,
  aspect,
  aspects,
  shape = "rect",
  zoom: zoomProp,
  defaultZoom,
  onZoomChange,
  minZoom = 1,
  maxZoom = 4,
  rotation: rotationProp,
  onRotationChange,
  onCropChange,
  className,
  ...props
}: ImageCropperProps) {
  const descriptionId = React.useId()
  const stageRef = React.useRef<HTMLDivElement>(null)
  const pictureRef = React.useRef<HTMLImageElement>(null)
  const clampZoom = (value: number) => Math.round(Math.min(maxZoom, Math.max(minZoom, value)) * 100) / 100
  const [stage, setStage] = React.useState<Size | null>(null)
  // Each keyed by the picture it belongs to, so a new src starts clean.
  const [picture, setPicture] = React.useState<(Size & { src: string }) | null>(null)
  const [failed, setFailed] = React.useState<string | null>(null)
  const [moved, setMoved] = React.useState({ src, ...ORIGIN })
  const [ownZoom, setOwnZoom] = React.useState(() => clampZoom(defaultZoom ?? minZoom))
  const [ownRotation, setOwnRotation] = React.useState<Rotation>(0)
  const [chosen, setChosen] = React.useState(() => aspect ?? aspects?.[0]?.value ?? 1)
  const [dragging, setDragging] = React.useState(false)

  const zoom = zoomProp ?? ownZoom
  const rotation = rotationProp ?? ownRotation
  const loaded = picture?.src === src ? picture : null
  const broken = failed === src
  const frame = stage ? frameIn(stage, aspects ? chosen : (aspect ?? 1)) : null
  const ready = Boolean(loaded && frame)
  const raw: CropState = { zoom, rotation, offset: moved.src === src ? { x: moved.x, y: moved.y } : ORIGIN }
  const state = loaded && frame ? clampOffset(raw, loaded, frame) : raw
  const area = loaded && frame ? cropAreaFor(state, loaded, frame) : null
  const scale = loaded && frame ? cropScale(state, loaded, frame) : 0

  const measure = React.useCallback(() => {
    const node = stageRef.current
    if (!node) return
    const { width, height } = node.getBoundingClientRect()
    setStage((current) => (current?.width === width && current.height === height ? current : { width, height }))
  }, [])

  React.useLayoutEffect(() => {
    measure()
    const node = stageRef.current
    if (!node || typeof ResizeObserver === "undefined") return
    const observer = new ResizeObserver(() => measure())
    observer.observe(node)
    return () => observer.disconnect()
  }, [measure])

  const settle = React.useCallback(() => {
    // A picture that loaded, or failed, before this page hydrated sent its
    // event to nobody: read where it stands instead.
    const node = pictureRef.current
    if (!node?.complete || !node.getAttribute("src")) return
    if (node.naturalWidth > 0) setPicture({ src, width: node.naturalWidth, height: node.naturalHeight })
    else setFailed(src)
  }, [src])
  React.useLayoutEffect(() => settle(), [settle])

  // The area is reported once it is known and after every change, from an
  // effect, so a parent that re-renders on it does not do so mid-render.
  const report = React.useRef(onCropChange)
  React.useLayoutEffect(() => {
    report.current = onCropChange
  })
  const { x, y, width, height }: Partial<CropArea> = area ?? {}
  React.useEffect(() => {
    if (x !== undefined && y !== undefined && width !== undefined && height !== undefined) report.current?.({ x, y, width, height })
  }, [x, y, width, height])

  const place = (next: CropState) => {
    if (!loaded || !frame) return
    const { offset } = clampOffset(next, loaded, frame)
    setMoved({ src, ...offset })
  }

  const zoomTo = (value: number, point: Point = ORIGIN) => {
    const next = clampZoom(value)
    if (!ready || next === zoom) return
    place(zoomAround(state, next, point))
    if (zoomProp === undefined) setOwnZoom(next)
    onZoomChange?.(next)
  }

  const rotate = () => {
    const next = ((rotation + 90) % 360) as Rotation
    if (rotationProp === undefined) setOwnRotation(next)
    onRotationChange?.(next)
  }

  /** A point on screen, measured from the frame's centre — which is the stage's. */
  const fromCentre = (point: Point): Point => {
    const box = stageRef.current?.getBoundingClientRect()
    return box ? { x: point.x - box.left - box.width / 2, y: point.y - box.top - box.height / 2 } : ORIGIN
  }

  // The wheel listens natively: React's own wheel handler is passive, and a
  // zoom that also scrolled the page would move the cropper out from under it.
  // A smooth wheel moves in steps far under the hundredth the zoom is kept
  // to, so the gesture's own zoom is kept unrounded here and only the zoom
  // it rounds to is applied and reported; it starts again from the zoom on
  // show whenever that has moved some other way.
  const wheel = React.useRef<(event: WheelEvent) => void>(() => {})
  const wheeled = React.useRef<number | null>(null)
  React.useLayoutEffect(() => {
    wheel.current = (event) => {
      if (!ready) return
      event.preventDefault()
      // In pixels, whatever the wheel counts in: a line is 16px, a page the stage.
      const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? (stage?.height ?? 0) : 1)
      const from = wheeled.current !== null && clampZoom(wheeled.current) === zoom ? wheeled.current : zoom
      const next = Math.min(maxZoom, Math.max(minZoom, from * Math.exp(-pixels * 0.0015)))
      wheeled.current = next
      zoomTo(next, fromCentre({ x: event.clientX, y: event.clientY }))
    }
  })
  React.useEffect(() => {
    const node = stageRef.current
    if (!node) return
    const onWheel = (event: WheelEvent) => wheel.current(event)
    node.addEventListener("wheel", onWheel, { passive: false })
    return () => node.removeEventListener("wheel", onWheel)
  }, [])

  // One pointer drags; two pinch. Each gesture is measured from where it
  // began, so events that land between renders lose nothing.
  const pointers = React.useRef(new Map<number, Point>())
  const gesture = React.useRef<{ from: Point; offset: Point } | { span: number; zoom: number } | null>(null)
  const begin = () => {
    const [first, second] = [...pointers.current.values()]
    gesture.current = second ? { span: distance(first, second), zoom } : first ? { from: first, offset: state.offset } : null
  }

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!ready || (event.pointerType === "mouse" && event.button !== 0)) return
    event.currentTarget.setPointerCapture?.(event.pointerId)
    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
    begin()
  }

  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!pointers.current.has(event.pointerId)) return
    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
    const [first, second] = [...pointers.current.values()]
    const now = gesture.current
    if (now && "from" in now && !second) {
      setDragging(true)
      place({ ...state, offset: { x: now.offset.x + first.x - now.from.x, y: now.offset.y + first.y - now.from.y } })
    } else if (now && "span" in now && second) {
      zoomTo((now.zoom * distance(first, second)) / now.span, fromCentre({ x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }))
    }
  }

  const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!pointers.current.delete(event.pointerId)) return
    event.currentTarget.releasePointerCapture?.(event.pointerId)
    begin()
    if (pointers.current.size === 0) setDragging(false)
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    // A key with Ctrl, Meta or Alt held is the browser's and the page's — its
    // own zoom, back and forward — never the cropper's.
    if (!ready || event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey) return
    // Physical keys on purpose: the picture is not a line of text, so
    // ArrowRight moves it right in a right-to-left page too.
    const step = event.shiftKey ? LEAP : STEP
    const moves: Record<string, Point> = { ArrowRight: { x: step, y: 0 }, ArrowLeft: { x: -step, y: 0 }, ArrowDown: { x: 0, y: step }, ArrowUp: { x: 0, y: -step } }
    const move = moves[event.key]
    if (move) place({ ...state, offset: { x: state.offset.x + move.x, y: state.offset.y + move.y } })
    else if (event.key === "+" || event.key === "=") zoomTo(zoom + ZOOM_STEP)
    else if (event.key === "-" || event.key === "_") zoomTo(zoom - ZOOM_STEP)
    else return
    event.preventDefault()
  }

  return (
    <div data-slot="image-cropper" data-shape={shape} data-status={broken ? "error" : loaded ? "ready" : "loading"} className={cn("flex w-full flex-col gap-3", className)} {...props}>
      <div
        ref={stageRef}
        data-slot="image-cropper-stage"
        data-dragging={dragging || undefined}
        role="group"
        aria-label="Crop area"
        aria-describedby={descriptionId}
        aria-disabled={!ready || undefined}
        tabIndex={0}
        onKeyDown={onKeyDown}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerEnd}
        onPointerCancel={onPointerEnd}
        className="group/stage relative aspect-[4/3] w-full cursor-grab touch-none overflow-hidden rounded-lg select-none focus-ring aria-disabled:cursor-default data-dragging:cursor-grabbing"
      >
        {/* The picture on the dark planes, as media is, so the overlay round
            the frame darkens in either theme; the focus outline stays on the
            stage, in the page's own accent. */}
        <div className="dark absolute inset-0 bg-background text-foreground">
          {/* Plain <img> rather than next/image: the cropper reads the picture's
              own pixel size and scales it itself; an optimised copy would be
              neither. */}
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            ref={pictureRef}
            src={src}
            alt={alt}
            draggable={false}
            onLoad={(event) => setPicture({ src, width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}
            onError={() => setFailed(src)}
            className={cn("pointer-events-none absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 select-none", !ready && "invisible")}
            style={loaded && frame ? { width: loaded.width * scale, height: loaded.height * scale, transform: `translate(${state.offset.x}px, ${state.offset.y}px) rotate(${rotation}deg)` } : undefined}
          />
          {ready && frame ? <CropFrame width={frame.width} height={frame.height} shape={shape} /> : null}
          {broken || !loaded ? <CropStatus broken={broken} /> : null}
        </div>
      </div>
      <p id={descriptionId} className="sr-only">
        Drag the picture, or use the arrow keys, to move it in the frame; Shift moves it further. Plus and minus zoom.
      </p>
      <div className="flex items-center gap-2">
        <ZoomOutIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
        <Slider
          aria-label="Zoom"
          min={minZoom}
          max={maxZoom}
          step={0.05}
          value={zoom}
          disabled={!ready}
          onValueChange={(next) => zoomTo(typeof next === "number" ? next : next[0])}
          getAriaValueText={(_, value) => `${Math.round(value * 100)}%`}
          className="min-w-0 flex-1"
        />
        <ZoomInIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
        <Button variant="outline" size="sm" className="ms-2" disabled={!ready} onClick={rotate}>
          <RotateCwIcon aria-hidden="true" />
          Rotate
        </Button>
      </div>
      {aspects ? (
        <SegmentedControl
          aria-label="Aspect ratio"
          size="sm"
          options={aspects.map((option) => ({ value: String(option.value), label: option.label }))}
          value={String(chosen)}
          onValueChange={(value) => setChosen(Number(value))}
          className="self-start"
        />
      ) : null}
    </div>
  )
}

export { ImageCropper }
components/ui/image-cropper/crop.ts
/** A quarter turn, clockwise. */
export type Rotation = 0 | 90 | 180 | 270

/** The part of the picture the frame holds, in the source's own pixels — after its rotation. */
export type CropArea = { x: number; y: number; width: number; height: number }

/**
 * Where the picture sits under the frame: how far it is zoomed past covering
 * the frame (1 covers it exactly), how far it has been moved from the centre
 * in screen pixels, and how far it is turned.
 */
export type CropState = { zoom: number; offset: { x: number; y: number }; rotation: Rotation }

type Size = { width: number; height: number }

/** The picture's size once turned: a quarter turn swaps its sides. */
export function rotatedSize(image: Size, rotation: Rotation): Size {
  return rotation % 180 === 0 ? { width: image.width, height: image.height } : { width: image.height, height: image.width }
}

/** Screen pixels per source pixel: just enough to cover the frame at zoom 1, and `zoom` times that. */
export function cropScale(state: CropState, image: Size, frame: Size): number {
  const source = rotatedSize(image, state.rotation)
  return Math.max(frame.width / source.width, frame.height / source.height) * state.zoom
}

/** The source pixels the frame holds, in whole pixels and never past the picture's edge. */
export function cropAreaFor(state: CropState, image: Size, frame: Size): CropArea {
  const source = rotatedSize(image, state.rotation)
  const scale = cropScale(state, image, frame)
  const width = Math.min(source.width, Math.round(frame.width / scale))
  const height = Math.min(source.height, Math.round(frame.height / scale))
  const x = (source.width - frame.width / scale) / 2 - state.offset.x / scale
  const y = (source.height - frame.height / scale) / 2 - state.offset.y / scale
  return {
    x: Math.min(source.width - width, Math.max(0, Math.round(x))),
    y: Math.min(source.height - height, Math.max(0, Math.round(y))),
    width,
    height,
  }
}

/** A negative zero (or NaN) read as the zero it means: the frame is centred, not -0px off it. */
const tidy = (value: number) => value || 0

/**
 * The state with the picture moved back inside the frame's reach: it can
 * travel half the amount it overhangs the frame each way, and a side that
 * only just covers cannot move at all. The state itself when nothing moved.
 */
export function clampOffset(state: CropState, image: Size, frame: Size): CropState {
  const source = rotatedSize(image, state.rotation)
  const scale = cropScale(state, image, frame)
  const reach = (length: number, frameLength: number) => Math.max(0, (length * scale - frameLength) / 2)
  const clamp = (value: number, limit: number) => tidy(Math.min(limit, Math.max(-limit, value)))
  const x = clamp(state.offset.x, reach(source.width, frame.width))
  const y = clamp(state.offset.y, reach(source.height, frame.height))
  return x === state.offset.x && y === state.offset.y ? state : { ...state, offset: { x, y } }
}

/**
 * The state zoomed to `zoom` around a point given from the frame's centre,
 * in screen pixels: the part of the picture under that point stays under it,
 * as a wheel or a pinch expects. Not clamped — pass the result to
 * `clampOffset`.
 */
export function zoomAround(state: CropState, zoom: number, point: { x: number; y: number }): CropState {
  const ratio = zoom / state.zoom
  return {
    ...state,
    zoom,
    offset: { x: tidy(point.x - (point.x - state.offset.x) * ratio), y: tidy(point.y - (point.y - state.offset.y) * ratio) },
  }
}

export type CropImageOptions = {
  /** The turn the area was measured under; the source is turned the same way before it is cut. */
  rotation?: Rotation
  /** The size of the picture handed back, in pixels: the area is scaled to it. */
  width: number
  height: number
  /** A browser that cannot encode the type hands back a PNG; the Blob's own type says which. */
  type?: "image/webp" | "image/png" | "image/jpeg"
  /** 0–1, for WebP and JPEG. */
  quality?: number
}

/** The picture at `src`, decoded. A picture from another server needs CORS headers, or the canvas will refuse to export it. */
function loadImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const image = new Image()
    image.crossOrigin = "anonymous"
    image.onload = () => resolve(image)
    image.onerror = () => reject(new Error(`The picture at ${src} could not be loaded.`))
    image.src = src
  })
}

/**
 * Cuts `area` out of the picture at `src`, turned by `rotation`, scaled to
 * `width` × `height`, and encodes it — WebP by default. Runs in the browser
 * only: it draws on a canvas, which is also why the picture never has to
 * leave the page to be cropped.
 */
export async function cropImage(src: string, area: CropArea, options: CropImageOptions): Promise<Blob> {
  const { rotation = 0, width, height, type = "image/webp", quality = 0.92 } = options
  const image = await loadImage(src)
  const canvas = document.createElement("canvas")
  canvas.width = width
  canvas.height = height
  const context = canvas.getContext("2d")
  if (!context) throw new Error("This browser cannot draw on a canvas.")
  context.imageSmoothingQuality = "high"
  // Into the area's own space — the turned picture's pixels — then turn the
  // source about the turned picture's centre and draw it there.
  context.scale(width / area.width, height / area.height)
  context.translate(-area.x, -area.y)
  const turned = rotatedSize({ width: image.naturalWidth, height: image.naturalHeight }, rotation)
  context.translate(turned.width / 2, turned.height / 2)
  context.rotate((rotation * Math.PI) / 180)
  context.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2)
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("The crop could not be encoded."))), type, quality)
  })
}
components/ui/image-cropper/frame.tsx
import { ImageOffIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"

/**
 * The frame over the picture. Its outside lies under the overlay — the
 * token every backdrop is drawn with, which darkens in either theme where
 * the ink at half strength would wash a dark page's picture white — drawn as
 * the frame's spread shadow, so a round frame dims everything outside its
 * circle. A thirds grid shows inside it while the stage is dragged (the
 * stage is the `stage` group), fading in and out, and simply appearing under
 * reduced motion. It is drawn on the dark planes, so its lines are light.
 */
function CropFrame({ width, height, shape }: { width: number; height: number; shape: "rect" | "round" }) {
  return (
    <div
      data-slot="image-cropper-frame"
      aria-hidden="true"
      className={cn(
        "pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 overflow-hidden shadow-[0_0_0_100vmax_var(--overlay)] ring-1 ring-foreground/70",
        shape === "round" ? "rounded-full" : "rounded-sm"
      )}
      style={{ width, height }}
    >
      {["start-1/3", "start-2/3"].map((at) => (
        <span key={at} className={cn("absolute inset-y-0 w-px bg-foreground/60 opacity-0 transition-opacity duration-(--duration-base) group-data-dragging/stage:opacity-100", at)} />
      ))}
      {["top-1/3", "top-2/3"].map((at) => (
        <span key={at} className={cn("absolute inset-x-0 h-px bg-foreground/60 opacity-0 transition-opacity duration-(--duration-base) group-data-dragging/stage:opacity-100", at)} />
      ))}
    </div>
  )
}

/** What the stage says while the picture loads, and when it cannot be loaded. */
function CropStatus({ broken }: { broken: boolean }) {
  return broken ? (
    <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-4 text-center">
      <ImageOffIcon aria-hidden="true" className="mb-1 size-5 text-muted-foreground" />
      <p className="text-sm font-medium">This picture can&apos;t be loaded</p>
      <p className="text-xs text-muted-foreground">The file is missing or is not an image.</p>
    </div>
  ) : (
    <div className="absolute inset-0 grid place-items-center">
      <Spinner aria-label="Loading picture" className="size-5 text-muted-foreground" />
    </div>
  )
}

export { CropFrame, CropStatus }