# Cropper

An image cropper with handles, a fixed or free aspect ratio, zoom, rotation and touch gestures.

```tsx title="hero.tsx"
"use client";
import * as React from "react";
import { Cropper, type CropperRootActions } from "@logic-ui/react/cropper";
import { classes, handleClasses, handlePositions, photos } from "./_classes";
import { FlipIcon, RotateIcon } from "./_icons";
import { ZoomDial } from "./_zoom-dial";

const ratios = [
  { label: "Free", value: undefined },
  { label: "Square", value: 1 },
  { label: "4:5", value: 4 / 5 },
  { label: "16:9", value: 16 / 9 },
];

/** A phone photo editor: aspect chips, a zoom dial, rotate, flip, and the result. */
export default function ExampleCropperHero() {
  const actionsRef = React.useRef<CropperRootActions>(null);
  const [aspectRatio, setAspectRatio] = React.useState<number | undefined>();
  const [result, setResult] = React.useState<string | null>(null);

  function done() {
    const canvas = actionsRef.current?.getCanvas({ maxWidth: 1200 });
    if (canvas) setResult(canvas.toDataURL("image/jpeg", 0.9));
  }

  if (result) {
    return (
      <div className={classes.phone}>
        <div className={classes.topBar}>
          <span />
          <p className={classes.title}>Saved</p>
          <button
            type="button"
            className={classes.doneButton}
            onClick={() => setResult(null)}
          >
            Edit
          </button>
        </div>
        <div className="flex h-[420px] items-center justify-center p-6">
          <img
            src={result}
            alt="The cropped photo"
            className="max-h-full max-w-full rounded-2xl shadow-2xl shadow-black"
          />
        </div>
        <div className="h-[132px]" />
      </div>
    );
  }

  return (
    <Cropper.Root
      src={photos.landscape}
      aspectRatio={aspectRatio}
      actionsRef={actionsRef}
      className={classes.phone}
    >
      <div className={classes.topBar}>
        <Cropper.Reset className={classes.textButton}>Reset</Cropper.Reset>
        <p className={classes.title}>Crop</p>
        <button type="button" className={classes.doneButton} onClick={done}>
          Done
        </button>
      </div>

      <Cropper.Viewport className={`${classes.viewport} h-[420px]`}>
        <Cropper.Image className={classes.image} />
        <Cropper.Stencil className={classes.stencil}>
          <Cropper.Grid className={classes.grid} />
          {handlePositions.map((position) => (
            <Cropper.Handle
              key={position}
              position={position}
              className={handleClasses[position]}
            />
          ))}
        </Cropper.Stencil>
      </Cropper.Viewport>

      <div className="flex flex-col gap-3 pt-4">
        <ZoomDial />
        <div className={classes.chips}>
          {ratios.map((ratio) => (
            <button
              key={ratio.label}
              type="button"
              aria-pressed={aspectRatio === ratio.value}
              onClick={() => setAspectRatio(ratio.value)}
              className={classes.chip}
            >
              {ratio.label}
            </button>
          ))}
        </div>
      </div>

      <div className={classes.toolbar}>
        <Cropper.Rotate angle={-90} className={classes.iconButton}>
          <RotateIcon direction="counterclockwise" />
        </Cropper.Rotate>
        <Cropper.Flip className={classes.iconButton}>
          <FlipIcon />
        </Cropper.Flip>
        <Cropper.Rotate className={classes.iconButton}>
          <RotateIcon direction="clockwise" />
        </Cropper.Rotate>
      </div>
    </Cropper.Root>
  );
}
```

```ts title="_classes.ts"
import type { CropperHandlePosition } from "@logic-ui/react/cropper";

/** Slide to the new place after a button or key press, like a phone editor. */
const settle =
  "data-transitioning:duration-(--cropper-transition-duration) data-transitioning:ease-[cubic-bezier(0.32,0.72,0,1)]";

/** Tailwind classes shared by the cropper demos: a dark, phone-style editor. */
export const classes = {
  // A phone-sized editor: black, edge to edge, rounded like a screen.
  phone:
    "relative mx-auto flex w-full max-w-[400px] flex-col overflow-hidden rounded-[2.5rem] border border-white/10 bg-black text-white shadow-[0_30px_80px_-20px_rgb(0_0_0/0.8)] select-none",
  topBar: "flex h-14 items-center justify-between px-6",
  title:
    "m-0 text-[11px] font-semibold tracking-[0.2em] text-white/60 uppercase",
  textButton:
    "-mx-2 cursor-pointer rounded-full border-none bg-transparent px-2 py-1 text-[15px] text-white transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-white disabled:cursor-default disabled:opacity-30",
  doneButton:
    "-mx-2 cursor-pointer rounded-full border-none bg-transparent px-2 py-1 text-[15px] font-semibold text-[#ffd60a] transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-[#ffd60a] disabled:cursor-default disabled:opacity-30",

  // The image fades in once it is on screen.
  viewport:
    "w-full cursor-grab bg-black opacity-0 transition-opacity duration-500 data-interacting:cursor-grabbing data-loaded:opacity-100",
  image: `${settle} data-transitioning:transition-transform`,

  // The crop: the image around it is dimmed, and less while you drag, so you
  // see what you leave out. A hairline frame; corner brackets are the handles.
  stencil: `shadow-[0_0_0_9999px_rgb(0_0_0/0.6)] ring-1 ring-white/70 ring-inset outline-none transition-shadow duration-300 focus-visible:ring-2 focus-visible:ring-[#ffd60a] data-interacting:shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] data-movable:cursor-move ${settle} data-transitioning:transition-[width,height,transform,box-shadow]`,

  // Rule of thirds, fading in while the crop or the image moves. Only the
  // inner lines: the frame draws the outer ones.
  grid: "pointer-events-none absolute inset-0 grid grid-cols-[repeat(var(--cropper-grid-columns),minmax(0,1fr))] grid-rows-[repeat(var(--cropper-grid-rows),minmax(0,1fr))] opacity-0 transition-opacity duration-300 data-interacting:opacity-100 *:border-white/40 *:not-data-[column='2']:border-r *:not-data-[row='2']:border-b",

  // Aspect ratio chips.
  chips: "flex justify-center gap-2 px-4",
  chip: "h-7 cursor-pointer rounded-full border-none bg-white/10 px-3 text-xs font-medium text-white/80 transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-pressed:bg-white aria-pressed:text-black",

  // Round icon buttons along the bottom.
  toolbar: "flex items-center justify-around px-6 pt-2 pb-6",
  iconButton:
    "flex size-11 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-white/90 transition-[background-color,opacity,scale] hover:bg-white/10 active:scale-90 focus-visible:outline-2 focus-visible:outline-white disabled:cursor-default disabled:opacity-30 disabled:hover:bg-transparent",

  text: "m-0 text-sm text-neutral-400",
  primaryButton:
    "flex h-9 cursor-pointer items-center justify-center gap-2 rounded-full border-none bg-white px-4 text-sm font-medium whitespace-nowrap text-black transition-[background-color,scale] select-none hover:bg-neutral-200 active:scale-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  button:
    "flex h-9 cursor-pointer items-center justify-center gap-2 rounded-full border-none bg-white/10 px-4 text-sm font-medium whitespace-nowrap text-white transition-colors select-none hover:bg-white/15 disabled:cursor-default disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
} as const;

/**
 * The handles: thick corner brackets, like a phone's photo editor, with a
 * 32 px target, drawn inside the crop so they never leave it, even at full
 * size; the edges are invisible strips you can drag.
 */
const corner =
  "absolute z-10 size-8 border-white outline-none drop-shadow-[0_1px_2px_rgb(0_0_0/0.5)] focus-visible:border-[#ffd60a]";
const edge = "absolute z-10 outline-none";

export const handleClasses: Record<CropperHandlePosition, string> = {
  "top-left": `${corner} top-0 left-0 cursor-nwse-resize rounded-tl-[3px] border-t-[3px] border-l-[3px]`,
  "top-right": `${corner} top-0 right-0 cursor-nesw-resize rounded-tr-[3px] border-t-[3px] border-r-[3px]`,
  "bottom-right": `${corner} right-0 bottom-0 cursor-nwse-resize rounded-br-[3px] border-r-[3px] border-b-[3px]`,
  "bottom-left": `${corner} bottom-0 left-0 cursor-nesw-resize rounded-bl-[3px] border-b-[3px] border-l-[3px]`,
  top: `${edge} inset-x-8 top-0 h-3 cursor-ns-resize`,
  bottom: `${edge} inset-x-8 bottom-0 h-3 cursor-ns-resize`,
  left: `${edge} inset-y-8 left-0 w-3 cursor-ew-resize`,
  right: `${edge} inset-y-8 right-0 w-3 cursor-ew-resize`,
};

/** The eight handles: four corners and four edges. */
export const handlePositions = Object.keys(
  handleClasses,
) as CropperHandlePosition[];

/** Photos served with CORS headers, so `getCanvas()` can read them. */
export const photos = {
  landscape: "https://picsum.photos/id/1015/1600/1067",
  portrait: "https://picsum.photos/id/1027/1067/1600",
} as const;
```

```tsx title="_icons.tsx"
import * as React from "react";

type IconProps = React.ComponentProps<"svg">;

/** A 22 px stroke icon, like a phone's toolbar: the shape is drawn by its children. */
function Icon(props: IconProps) {
  return (
    <svg
      width="22"
      height="22"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      {...props}
    />
  );
}

export function ZoomInIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="11" cy="11" r="6.5" />
      <path d="M11 8.5v5M8.5 11h5M16 16l4 4" />
    </Icon>
  );
}

export function ZoomOutIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <circle cx="11" cy="11" r="6.5" />
      <path d="M8.5 11h5M16 16l4 4" />
    </Icon>
  );
}

/** A square with an arrow turning it a quarter. */
export function RotateIcon(
  props: IconProps & { direction: "clockwise" | "counterclockwise" },
) {
  const { direction, ...iconProps } = props;
  return (
    <Icon
      {...iconProps}
      style={direction === "clockwise" ? { scale: "-1 1" } : undefined}
    >
      <rect x="4" y="10" width="10" height="10" rx="1.5" />
      <path d="M8 6.5A8 8 0 0 1 19.5 13" />
      <path d="m5.5 5 2.5 1.5L6.5 9" />
    </Icon>
  );
}

/** Two triangles mirrored across a dashed line. */
export function FlipIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M12 3v18" strokeDasharray="2 2.5" />
      <path d="M9 7 3.5 17H9zM15 7l5.5 10H15z" />
    </Icon>
  );
}

/** An arrow going back around: start over. */
export function ResetIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M4 12a8 8 0 1 0 2.4-5.7" />
      <path d="M4 4v4.5h4.5" />
    </Icon>
  );
}

/** A frame with corner marks: the crop tool. */
export function CropIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M6 2v14a2 2 0 0 0 2 2h14" />
      <path d="M2 6h14a2 2 0 0 1 2 2v14" />
    </Icon>
  );
}
```

```tsx title="_zoom-dial.tsx"
"use client";
import * as React from "react";
import {
  useCropperRootContext,
  useCropperRootStateContext,
} from "@logic-ui/react/cropper";
import { Slider } from "@logic-ui/react/slider";

/**
 * A ruler you scrub to zoom, like a phone's photo editor: a Base UI slider
 * bound to the cropper's zoom, from 0 (whole image) to 1 (closest), with
 * ticks and a yellow needle.
 */
export function ZoomDial() {
  const { actions, status } = useCropperRootContext();
  const { zoom } = useCropperRootStateContext();
  return (
    <div className="flex flex-col items-center gap-1 px-8">
      <span className="font-mono text-[11px] text-[#ffd60a] tabular-nums">
        {Math.round(zoom * 100)}
      </span>
      <Slider.Root
        aria-label="Zoom"
        min={0}
        max={1}
        step={0.005}
        value={zoom}
        disabled={status !== "loaded"}
        onValueChange={(value) => actions.setZoom(value as number)}
        className="w-full touch-none"
      >
        <Slider.Control className="flex h-9 cursor-ew-resize items-center">
          {/* A tick every 5%, a long one every 25%, fading out at both ends. */}
          <Slider.Track className="relative h-7 w-full">
            <div
              aria-hidden
              className="absolute inset-x-0 top-1/2 h-4 -translate-y-1/2 bg-[repeating-linear-gradient(90deg,rgb(255_255_255/0.75)_0_1px,transparent_1px_25%),repeating-linear-gradient(90deg,rgb(255_255_255/0.3)_0_1px,transparent_1px_5%)] bg-[length:calc(100%+1px)_100%,calc(100%+1px)_60%] bg-center bg-no-repeat [mask-image:linear-gradient(90deg,transparent,black_12%,black_88%,transparent)]"
            />
            <Slider.Thumb className="h-7 w-[3px] rounded-full bg-[#ffd60a] outline-none focus-visible:ring-2 focus-visible:ring-[#ffd60a]/50 focus-visible:ring-offset-2 focus-visible:ring-offset-black" />
          </Slider.Track>
        </Slider.Control>
      </Slider.Root>
    </div>
  );
}
```

## Usage guidelines

- **Everything is headless:** the parts place the image and the crop, and handle the gestures, the keyboard and the math. How the crop, the handles, the grid and the darkened area look is yours, through data attributes.
- **One gesture engine:** dragging a handle, the crop or the image, pinching and the wheel are handled together in `Cropper.Viewport`, with pointer events, so mouse, pen and touch work the same and never fight over a press.
- **Image pixels in, image pixels out:** `defaultCoordinates`, `minWidth`, `onCoordinatesChange` and `getCanvas()` all speak in pixels of the image, whatever its size on screen.
- **Ported from Advanced Cropper:** the algorithms come from [advanced-cropper](https://github.com/advanced-cropper/advanced-cropper) and [react-advanced-cropper](https://github.com/advanced-cropper/react-advanced-cropper) by Norserium, merged into one entry point. The rectangle and circle stencils are one `Cropper.Stencil`, lines and handles one `Cropper.Handle`, and `FixedCropper` is the `stencilSize` prop.

## Anatomy

Import the component and assemble its parts:

```jsx title="Anatomy"
import { Cropper } from "@logic-ui/react/cropper";

<Cropper.Root>
  <Cropper.Viewport>
    <Cropper.Image />
    <Cropper.Stencil>
      <Cropper.Grid />
      <Cropper.Handle position="top-left" />
    </Cropper.Stencil>
  </Cropper.Viewport>
  <Cropper.Preview />
  <Cropper.ZoomIn />
  <Cropper.ZoomOut />
  <Cropper.Rotate />
  <Cropper.Flip />
  <Cropper.Reset />
</Cropper.Root>;
```

## Examples

### Getting started

`Cropper.Root` loads `src` and holds the crop. Give `Cropper.Viewport` a size: the image fits in it and the crop starts at 80% of the image. Without handles, the crop keeps its size and moves.

```tsx title="minimal.tsx"
"use client";
import * as React from "react";
import { Cropper } from "@logic-ui/react/cropper";
import { classes, photos } from "./_classes";

/** The fewest parts: drag the crop, drag and zoom the image. No handles, so the crop keeps its size. */
export default function ExampleCropperMinimal() {
  return (
    <Cropper.Root
      src={photos.landscape}
      aspectRatio={1}
      className="w-full overflow-hidden rounded-3xl"
    >
      <Cropper.Viewport className={`${classes.viewport} h-80 sm:h-96`}>
        <Cropper.Image className={classes.image} />
        <Cropper.Stencil className={classes.stencil} />
      </Cropper.Viewport>
    </Cropper.Root>
  );
}
```

```tsx title="A square crop"
<Cropper.Root src={url} aspectRatio={1}>
  <Cropper.Viewport className="h-96 w-full">
    <Cropper.Image />
    <Cropper.Stencil className="shadow-[0_0_0_9999px_rgb(0_0_0/0.6)] outline outline-white" />
  </Cropper.Viewport>
</Cropper.Root>
```

### Handles

`Cropper.Handle` resizes the crop from a corner (`top-left`, `top-right`, `bottom-right`, `bottom-left`) or an edge (`top`, `right`, `bottom`, `left`). It has `data-position`: place it with that. An edge handle can be a strip along the edge, as in the demo above, or a small bar in its middle. Hold Shift while dragging to keep the aspect ratio.

```tsx title="A corner bracket, like a phone's editor"
<Cropper.Handle
  position="top-left"
  className="absolute top-0 left-0 size-8 border-t-[3px] border-l-[3px] border-white"
/>
```

### Aspect ratio and size

`aspectRatio` takes a number or a range, `{ minimum: 1, maximum: 2 }`. `minWidth`, `minHeight`, `maxWidth` and `maxHeight` are in image pixels. When they change, the crop is fitted to them around its center.

```tsx title="Between square and 2:1, at least 200 px wide"
<Cropper.Root src={url} aspectRatio={{ minimum: 1, maximum: 2 }} minWidth={200}>
```

### Fixed stencil

`stencilSize` keeps the crop's size on screen: the image moves and zooms behind it, and the crop can't be moved or resized. Round it with `rounded-full` for a profile picture, and drive the zoom from a Base UI slider with `useCropperRootContext()` and `useCropperRootStateContext()`.

```tsx title="avatar.tsx"
"use client";
import * as React from "react";
import { Cropper } from "@logic-ui/react/cropper";
import { classes, photos } from "./_classes";
import { ZoomDial } from "./_zoom-dial";

/**
 * A profile picture: the circle keeps its size and the photo moves and zooms
 * behind it. A ruler drives the zoom; the preview shows the result live.
 */
export default function ExampleCropperAvatar() {
  return (
    <Cropper.Root
      src={photos.portrait}
      stencilSize={{ width: 260, height: 260 }}
      imageRestriction="stencil"
      className={classes.phone}
    >
      <div className={classes.topBar}>
        <span className="w-14" />
        <p className={classes.title}>Move and scale</p>
        <Cropper.Preview className="size-9 shrink-0 rounded-full bg-white/10 ring-2 ring-white/20" />
      </div>
      <Cropper.Viewport className={`${classes.viewport} h-[420px]`}>
        <Cropper.Image className={classes.image} />
        <Cropper.Stencil
          aria-label="Profile picture"
          className={`${classes.stencil} rounded-full`}
        />
      </Cropper.Viewport>
      <div className="pt-4 pb-6">
        <ZoomDial />
      </div>
    </Cropper.Root>
  );
}
```

```tsx title="A zoom slider"
const { actions } = useCropperRootContext();
const { zoom } = useCropperRootStateContext();

<Slider.Root
  min={0}
  max={1}
  step={0.01}
  value={zoom}
  onValueChange={(value) => actions.setZoom(value as number)}
>
```

### Where the image may go

`imageRestriction` sets how far the image can move and zoom out:

- `"fit-area"` (default): the image can't be zoomed out past the viewport, and the crop stays on the image.
- `"fill-area"`: the image always fills the viewport.
- `"stencil"`: only the crop stays on the image, the view is free. Best with `stencilSize`.
- `"none"`: no limit; the crop can leave the image, and `getCanvas()` fills the rest with `fillColor`.

### Getting the result

`actionsRef` receives the actions. `getCanvas()` draws the crop on a canvas, rotated and flipped, at its size in image pixels or at the size you ask: `width`, `height`, `maxWidth`, `maxHeight` or `maxArea`. For an image from another origin, the server must send CORS headers: `crossOrigin` is `"anonymous"` by default.

```tsx title="Upload the crop"
const actionsRef = React.useRef<CropperRootActions>(null);

async function upload() {
  const canvas = actionsRef.current?.getCanvas({ maxWidth: 1600 });
  canvas?.toBlob((blob) => blob && send(blob), "image/webp");
}

<Cropper.Root src={url} actionsRef={actionsRef}>
```

`onCoordinatesChange` reports the crop in whole image pixels as it changes; `onCoordinatesCommitted` once a gesture ends or after a button or key press, like a slider's `onValueCommitted`. Both receive `eventDetails.reason`: `"handle-drag"`, `"wheel"`, `"keyboard"`, `"zoom-in-press"` …

To send the crop to a server that crops, use `getCoordinates()` with `getState().transforms` for the rotation and flip.

### In a dialog

Put the cropper in a Base UI `Dialog.Popup` to crop a photo the user picks: give the file an object URL as `src`, open the dialog, and read `getCanvas()` when they save. `Cropper.Viewport` measures itself when the popup opens, and again if its size changes, so nothing has to wait for the dialog.

The profile card below takes a click or a dropped image on its cover or its photo, and opens an editor shaped for each: a fixed circle for the photo (`stencilSize` from the viewport's size), a 3:1 frame for the cover. The editor is a sheet on phones and a card on larger screens, with frosted bars over the photo, a zoom dial, rotate, flip and reset, and a live `Cropper.Preview` at the result's shape. It shows a spinner while the image loads and a message when it can't be opened (`data-loading`, `data-error`), and Save waits for the image.

```tsx title="dialog.tsx"
"use client";
import * as React from "react";
import {
  Cropper,
  type CropperCoordinates,
  type CropperRootActions,
  type CropperRootStatus,
  type CropperState,
} from "@logic-ui/react/cropper";
import { Dialog } from "@logic-ui/react/dialog";
import { cn } from "cn";
import { handleClasses, handlePositions, photos } from "./_classes";
import { FlipIcon, ResetIcon, RotateIcon } from "./_icons";
import { ZoomDial } from "./_zoom-dial";

/** What is being cropped: each has its own shape, output size and labels. */
type Target = "avatar" | "cover";

const targets = {
  avatar: {
    title: "Profile photo",
    // A circle that keeps its size: the photo moves and zooms behind it.
    stencilSize: (state: CropperState) => {
      const side = Math.min(state.boundary.width, state.boundary.height) * 0.78;
      return { width: side, height: side };
    },
    aspectRatio: 1,
    output: { width: 512, height: 512 },
    sample: photos.portrait,
  },
  cover: {
    title: "Cover",
    // A 3:1 banner across the width.
    stencilSize: (state: CropperState) => {
      const width = Math.min(
        state.boundary.width * 0.92,
        state.boundary.height * 2.7,
      );
      return { width, height: width / 3 };
    },
    aspectRatio: 3,
    output: { width: 1500, height: 500 },
    sample: photos.landscape,
  },
} as const;

/** Tailwind classes of this demo. */
const local = {
  // The profile card.
  Card: "w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-neutral-950 text-white shadow-[0_30px_80px_-30px_rgb(0_0_0/0.9)] select-none",
  // Drop zones: a yellow ring while a file is dragged over them.
  Cover:
    "group relative block aspect-[3/1] w-full cursor-pointer overflow-hidden border-none bg-[radial-gradient(90%_140%_at_0%_0%,rgb(56_189_248/0.7),transparent_60%),radial-gradient(80%_140%_at_100%_100%,rgb(251_191_36/0.55),transparent_60%),radial-gradient(60%_100%_at_70%_0%,rgb(167_139_250/0.6),transparent_70%),linear-gradient(135deg,#312e81,#0f172a)] p-0 outline-none after:pointer-events-none after:absolute after:inset-0 after:ring-2 after:ring-[#ffd60a] after:ring-inset after:opacity-0 after:transition-opacity data-dragging:after:opacity-100 has-focus-visible:after:opacity-100",
  Avatar:
    "group relative z-10 -mt-12 ml-5 flex size-24 cursor-pointer items-center justify-center overflow-hidden rounded-full border-none bg-gradient-to-br from-violet-500 to-sky-500 p-0 text-2xl font-semibold text-white ring-4 ring-neutral-950 outline-none after:pointer-events-none after:absolute after:inset-0 after:rounded-full after:ring-2 after:ring-[#ffd60a] after:ring-inset after:opacity-0 after:transition-opacity data-dragging:after:opacity-100 has-focus-visible:after:opacity-100",
  Fill: "absolute inset-0 size-full object-cover",
  // A camera badge over the photo on hover, focus or drag.
  Hover:
    "absolute inset-0 flex items-center justify-center gap-2 bg-black/45 text-sm font-medium opacity-0 backdrop-blur-[2px] transition-opacity group-hover:opacity-100 group-has-focus-visible:opacity-100 group-data-dragging:opacity-100",
  Body: "flex flex-col gap-4 px-5 pt-3 pb-5",
  Name: "m-0 text-lg font-semibold",
  Handle: "m-0 text-sm text-white/50",
  Row: "flex flex-wrap items-center gap-2",
  Chip: "flex h-8 cursor-pointer items-center gap-1.5 rounded-full border-none bg-white/10 px-3 text-xs font-medium text-white/90 transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  Remove:
    "flex h-8 cursor-pointer items-center gap-1.5 rounded-full border-none bg-transparent px-3 text-xs font-medium text-red-300/90 transition-colors hover:bg-red-500/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-300",
  Hint: "m-0 text-xs text-white/40",
  Error: "m-0 rounded-xl bg-red-500/10 px-3 py-2 text-xs text-red-300",

  // The editor: a sheet on phones, a card on larger screens. The cropper
  // fills it under frosted bars; the viewport's padding keeps the crop clear
  // of them.
  Backdrop:
    "fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0",
  Popup:
    "fixed inset-x-0 bottom-0 h-[min(680px,92dvh)] overflow-hidden rounded-t-[2rem] border border-white/10 bg-black text-white outline-none transition-[translate,opacity,scale] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] select-none [--bar-top:3.75rem] [--bar-bottom:calc(9.5rem+env(safe-area-inset-bottom))] data-ending-style:translate-y-full data-starting-style:translate-y-full sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:h-[640px] sm:w-[460px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-[2rem] sm:[--bar-bottom:9.5rem] sm:data-ending-style:-translate-y-1/2 sm:data-ending-style:scale-95 sm:data-ending-style:opacity-0 sm:data-starting-style:-translate-y-1/2 sm:data-starting-style:scale-95 sm:data-starting-style:opacity-0",
  Frost:
    "border-white/10 bg-neutral-950/55 backdrop-blur-xl backdrop-saturate-150",
  TopBar:
    "absolute inset-x-0 top-0 z-20 flex h-(--bar-top) items-center justify-between border-b px-5",
  Title:
    "m-0 flex flex-col items-center text-[11px] font-semibold tracking-[0.2em] text-white/60 uppercase [&_span]:font-mono [&_span]:tracking-normal [&_span]:text-white/40 [&_span]:normal-case",
  TextButton:
    "-mx-2 cursor-pointer rounded-full border-none bg-transparent px-2 py-1 text-[15px] text-white transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-white",
  SaveButton:
    "-mx-2 cursor-pointer rounded-full border-none bg-transparent px-2 py-1 text-[15px] font-semibold text-[#ffd60a] transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-[#ffd60a] disabled:cursor-default disabled:opacity-30",
  Viewport:
    "absolute inset-0 cursor-grab bg-black pt-(--bar-top) pb-(--bar-bottom) opacity-0 transition-opacity duration-500 data-interacting:cursor-grabbing data-loaded:opacity-100",
  Image:
    "data-transitioning:transition-transform data-transitioning:duration-(--cropper-transition-duration) data-transitioning:ease-[cubic-bezier(0.32,0.72,0,1)]",
  Stencil:
    "shadow-[0_0_0_9999px_rgb(0_0_0/0.65)] ring-1 ring-white/70 ring-inset outline-none transition-shadow duration-300 focus-visible:ring-2 focus-visible:ring-[#ffd60a] data-interacting:shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] data-transitioning:transition-[width,height,transform,box-shadow] data-transitioning:duration-(--cropper-transition-duration) data-transitioning:ease-[cubic-bezier(0.32,0.72,0,1)]",
  // Shown while the image loads, or when it can't be opened.
  Status:
    "pointer-events-none absolute inset-x-0 top-(--bar-top) bottom-(--bar-bottom) z-10 hidden flex-col items-center justify-center gap-3 text-sm text-white/60 in-data-error:flex in-data-loading:flex",
  Spinner:
    "size-7 animate-spin rounded-full border-2 border-white/15 border-t-white in-data-error:hidden",
  BottomBar:
    "absolute inset-x-0 bottom-0 z-20 flex h-(--bar-bottom) flex-col justify-center gap-2 border-t pb-[env(safe-area-inset-bottom)]",
  Tools: "flex items-center justify-between px-5",
  ToolGroup: "flex items-center gap-1",
  Tool: "flex size-10 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-white/90 transition-[background-color,opacity,scale] hover:bg-white/10 active:scale-90 focus-visible:outline-2 focus-visible:outline-white disabled:cursor-default disabled:opacity-30 disabled:hover:bg-transparent",
  // The result, live, at its real shape.
  Result: "flex items-center gap-3 font-mono text-[11px] text-white/45",
  AvatarPreview: "size-10 rounded-full bg-white/10 ring-2 ring-white/15",
  CoverPreview: "h-8 w-24 rounded-md bg-white/10 ring-2 ring-white/15",
};

/** Whether a drag carries files. */
function hasFiles(event: React.DragEvent): boolean {
  return Array.from(event.dataTransfer.types).includes("Files");
}

/**
 * An "Edit profile" card: click or drop an image on the cover or the photo,
 * crop it in a dialog shaped for each (a fixed circle, a 3:1 banner), and see
 * the result on the card. On a phone the dialog is a sheet; on a larger
 * screen, a card.
 */
export default function ExampleCropperDialog() {
  const [images, setImages] = React.useState<Record<Target, string | null>>({
    avatar: null,
    cover: null,
  });
  const [editing, setEditing] = React.useState<{
    target: Target;
    src: string;
  } | null>(null);
  const [open, setOpen] = React.useState(false);
  const [dragging, setDragging] = React.useState<Target | null>(null);
  const [error, setError] = React.useState<string | null>(null);

  // Object URLs made for chosen files are released once replaced.
  const src = editing?.src;
  React.useEffect(() => {
    if (!src?.startsWith("blob:")) return undefined;
    return () => URL.revokeObjectURL(src);
  }, [src]);

  function startEditing(target: Target, nextSrc: string) {
    setError(null);
    setEditing({ target, src: nextSrc });
    setOpen(true);
  }

  function edit(target: Target, file: File | undefined) {
    if (!file) return;
    if (!file.type.startsWith("image/")) {
      setError(`“${file.name}” isn't an image. Choose a JPEG, PNG or WebP.`);
      return;
    }
    startEditing(target, URL.createObjectURL(file));
  }

  function dropZone(target: Target) {
    return {
      "data-dragging": dragging === target ? "" : undefined,
      onDragEnter: (event: React.DragEvent) => {
        if (hasFiles(event)) setDragging(target);
      },
      onDragOver: (event: React.DragEvent) => {
        if (hasFiles(event)) event.preventDefault();
      },
      onDragLeave: (event: React.DragEvent) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
          setDragging(null);
        }
      },
      onDrop: (event: React.DragEvent) => {
        event.preventDefault();
        setDragging(null);
        edit(target, event.dataTransfer.files[0]);
      },
    };
  }

  return (
    <div className={local.Card}>
      <label className={local.Cover} {...dropZone("cover")}>
        <FileInput target="cover" label="Change cover" onFile={edit} />
        {images.cover && (
          <img src={images.cover} alt="" className={local.Fill} />
        )}
        <span className={local.Hover}>
          <CameraIcon /> {images.cover ? "Change cover" : "Add a cover"}
        </span>
      </label>
      <label className={local.Avatar} {...dropZone("avatar")}>
        <FileInput target="avatar" label="Change profile photo" onFile={edit} />
        {images.avatar ? (
          <img src={images.avatar} alt="" className={local.Fill} />
        ) : (
          "AM"
        )}
        <span className={cn(local.Hover, "rounded-full")}>
          <CameraIcon />
        </span>
      </label>

      <div className={local.Body}>
        <div>
          <p className={local.Name}>Ava Martin</p>
          <p className={local.Handle}>@ava · Photographer</p>
        </div>
        {error && (
          <p role="alert" className={local.Error}>
            {error}
          </p>
        )}
        <div className={local.Row}>
          <button
            type="button"
            className={local.Chip}
            onClick={() => startEditing("avatar", targets.avatar.sample)}
          >
            Try a sample photo
          </button>
          <button
            type="button"
            className={local.Chip}
            onClick={() => startEditing("cover", targets.cover.sample)}
          >
            Try a sample cover
          </button>
          {(images.avatar || images.cover) && (
            <button
              type="button"
              className={local.Remove}
              onClick={() => setImages({ avatar: null, cover: null })}
            >
              <TrashIcon /> Remove
            </button>
          )}
        </div>
        <p className={local.Hint}>
          Click the cover or the photo, or drop an image on it.
        </p>
      </div>

      <Dialog.Root open={open} onOpenChange={setOpen}>
        <Dialog.Portal>
          <Dialog.Backdrop className={local.Backdrop} />
          <Dialog.Popup className={local.Popup}>
            {editing && (
              <Editor
                key={editing.target + editing.src}
                target={editing.target}
                src={editing.src}
                onSave={(result) => {
                  setImages((current) => ({
                    ...current,
                    [editing.target]: result,
                  }));
                  setOpen(false);
                }}
              />
            )}
          </Dialog.Popup>
        </Dialog.Portal>
      </Dialog.Root>
    </div>
  );
}

/**
 * A file input, hidden but focusable, inside a drop zone's label: a click on
 * the zone opens the file picker, and the keyboard reaches it.
 */
function FileInput(props: {
  target: Target;
  label: string;
  onFile: (target: Target, file: File | undefined) => void;
}) {
  const { target, label, onFile } = props;
  return (
    <input
      type="file"
      accept="image/*"
      aria-label={label}
      className="sr-only"
      onChange={(event) => {
        onFile(target, event.target.files?.[0]);
        event.target.value = "";
      }}
    />
  );
}

/** The cropper in the dialog, shaped for the photo or the cover. */
function Editor(props: {
  target: Target;
  src: string;
  onSave: (result: string) => void;
}) {
  const { target, src, onSave } = props;
  const config = targets[target];
  const actionsRef = React.useRef<CropperRootActions>(null);
  const [status, setStatus] = React.useState<CropperRootStatus>("loading");
  const [size, setSize] = React.useState<CropperCoordinates | null>(null);

  function save() {
    const canvas = actionsRef.current?.getCanvas(config.output);
    if (canvas) onSave(canvas.toDataURL("image/jpeg", 0.9));
  }

  return (
    <Cropper.Root
      src={src}
      stencilSize={config.stencilSize}
      aspectRatio={config.aspectRatio}
      imageRestriction="stencil"
      actionsRef={actionsRef}
      onStatusChange={setStatus}
      onCoordinatesChange={setSize}
      className="absolute inset-0"
    >
      <Cropper.Viewport className={local.Viewport}>
        <Cropper.Image className={local.Image} />
        <Cropper.Stencil
          aria-label={config.title}
          className={cn(local.Stencil, target === "avatar" && "rounded-full")}
        >
          {/* Fixed in size: the corners only frame the cover. */}
          {target === "cover" &&
            handlePositions
              .filter((position) => position.includes("-"))
              .map((position) => (
                <Cropper.Handle
                  key={position}
                  position={position}
                  className={handleClasses[position]}
                />
              ))}
        </Cropper.Stencil>
      </Cropper.Viewport>

      <div className={local.Status} aria-live="polite">
        <span className={local.Spinner} />
        {status === "error" ? "This image can't be opened." : "Loading…"}
      </div>

      <div className={cn(local.TopBar, local.Frost)}>
        <Dialog.Close className={local.TextButton}>Cancel</Dialog.Close>
        <Dialog.Title className={local.Title}>
          {config.title}
          <span>{size ? `${size.width} × ${size.height}` : "…"}</span>
        </Dialog.Title>
        <button
          type="button"
          onClick={save}
          disabled={status !== "loaded"}
          className={local.SaveButton}
        >
          Save
        </button>
      </div>
      <Dialog.Description className="sr-only">
        Drag the photo to place it in the frame, and zoom with the dial, the
        wheel or a pinch.
      </Dialog.Description>

      <div className={cn(local.BottomBar, local.Frost)}>
        <ZoomDial />
        <div className={local.Tools}>
          <div className={local.ToolGroup}>
            <Cropper.Rotate angle={-90} className={local.Tool}>
              <RotateIcon direction="counterclockwise" />
            </Cropper.Rotate>
            <Cropper.Flip className={local.Tool}>
              <FlipIcon />
            </Cropper.Flip>
            <Cropper.Reset className={local.Tool}>
              <ResetIcon />
            </Cropper.Reset>
          </div>
          <div className={local.Result}>
            {config.output.width} × {config.output.height}
            <Cropper.Preview
              aria-hidden
              className={
                target === "avatar" ? local.AvatarPreview : local.CoverPreview
              }
            />
          </div>
        </div>
      </div>
    </Cropper.Root>
  );
}

type IconProps = React.ComponentProps<"svg">;

function Icon(props: IconProps) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      {...props}
    />
  );
}

function CameraIcon(props: IconProps) {
  return (
    <Icon width="18" height="18" {...props}>
      <path d="M4 8.5A2.5 2.5 0 0 1 6.5 6h1.3l1.4-2h5.6l1.4 2h1.3A2.5 2.5 0 0 1 20 8.5v8a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 16.5z" />
      <circle cx="12" cy="12.5" r="3.5" />
    </Icon>
  );
}

function TrashIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13" />
    </Icon>
  );
}
```

```tsx title="Crop a chosen file"
function choose(event: React.ChangeEvent<HTMLInputElement>) {
  const file = event.target.files?.[0];
  if (file) {
    setSrc(URL.createObjectURL(file));
    setOpen(true);
  }
}

<Dialog.Root open={open} onOpenChange={setOpen}>
  <Dialog.Portal>
    <Dialog.Backdrop />
    <Dialog.Popup>
      <Dialog.Title>Crop your photo</Dialog.Title>
      <Cropper.Root src={src} aspectRatio={1} actionsRef={actionsRef}>
        <Cropper.Viewport className="h-80">…</Cropper.Viewport>
        <Dialog.Close>Cancel</Dialog.Close>
        <button onClick={save}>Save</button>
      </Cropper.Root>
    </Dialog.Popup>
  </Dialog.Portal>
</Dialog.Root>;
```

Revoke the object URL (`URL.revokeObjectURL`) once it is no longer shown.

### Account picture, step by step

A profile picture flow in the style of Google's account settings, in Material 3's dark colours: one dialog walks through the steps, each sliding in from the side it comes from.

1. **Profile picture** shows the current picture, with **Change** and **Remove**; removing asks first and shows what replaces it.
2. **Change profile picture** offers your photos in a grid, or an upload: drag a photo onto the drop zone or pick one, in Base UI `Tabs` with a sliding indicator.
3. **Crop & rotate** shows a square crop with the circle people will see, round handles on its corners, and **Rotate** and **Reset**. **Save as profile picture** draws 512 × 512 px with `getCanvas()`, shows its progress, and returns to the first step with the new picture.

```tsx title="account.tsx"
"use client";
import * as React from "react";
import { Cropper, type CropperRootActions } from "@logic-ui/react/cropper";
import { Dialog } from "@logic-ui/react/dialog";
import { Tabs } from "@logic-ui/react/tabs";
import { cn } from "cn";
import { getThumbnailSrc, photos } from "./_lightbox";

/** The steps of the dialog, in order. */
type Step = "view" | "remove" | "choose" | "crop";

/** Where the next step comes from, or `none` when the dialog opens. */
type Direction = "forward" | "back" | "none";

/**
 * Material 3 dark colours: surfaces from dark to light, the primary blue,
 * the text and the outline.
 */
const m3 = {
  surface: "bg-[#1e1f20]",
  container: "bg-[#282a2c]",
  containerHigh: "bg-[#333537]",
  primary: "bg-[#a8c7fa] text-[#062e6f]",
  primaryText: "text-[#a8c7fa]",
  text: "text-[#e3e3e3]",
  muted: "text-[#c4c7c5]",
  outline: "border-[#444746]",
};

/** Tailwind classes of this demo. */
const local = {
  // The account header on the page.
  Account: cn(
    "flex w-full max-w-md items-center gap-4 rounded-[28px] border px-5 py-4 select-none",
    m3.surface,
    m3.outline,
    m3.text,
  ),
  // The badge sits outside the circle, ringed in the card's colour, so
  // nothing clips it.
  AccountButton:
    "group relative size-16 flex-none cursor-pointer rounded-full border-none bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-[#a8c7fa] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e1f20]",
  AccountShade:
    "pointer-events-none absolute inset-0 rounded-full bg-black/35 opacity-0 transition-opacity duration-200 group-hover:opacity-100",
  CameraBadge: cn(
    "absolute -right-1 -bottom-1 flex size-7 items-center justify-center rounded-full ring-[3px] ring-[#1e1f20]",
    m3.containerHigh,
    m3.text,
  ),
  AccountText: "flex min-w-0 flex-1 flex-col gap-0.5",
  Name: "m-0 text-base font-medium",
  Email: cn("m-0 truncate text-sm", m3.muted),
  ManageButton: cn(
    "flex h-9 flex-none cursor-pointer items-center rounded-full border bg-transparent px-4 text-sm font-medium outline-none transition-colors hover:bg-[#a8c7fa]/8 focus-visible:ring-2 focus-visible:ring-[#a8c7fa]",
    m3.outline,
    m3.primaryText,
  ),

  // The dialog: full screen on phones, a 28 px card on larger screens.
  Backdrop:
    "fixed inset-0 bg-black/70 backdrop-blur-[2px] transition-opacity duration-[400ms] ease-[cubic-bezier(0.05,0.7,0.1,1)] data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:opacity-0",
  Popup: cn(
    "fixed inset-0 flex flex-col overflow-hidden outline-none select-none sm:inset-auto sm:top-1/2 sm:left-1/2 sm:h-[min(640px,calc(100dvh-2rem))] sm:w-[560px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-[28px] sm:shadow-[0_8px_40px_rgb(0_0_0/0.6)]",
    "origin-center transition-[opacity,scale,translate] duration-[400ms] ease-[cubic-bezier(0.05,0.7,0.1,1)] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-[cubic-bezier(0.3,0,0.8,0.15)] data-starting-style:opacity-0 max-sm:data-ending-style:translate-y-6 max-sm:data-starting-style:translate-y-6 sm:data-ending-style:scale-[0.96] sm:data-starting-style:scale-[0.92]",
    m3.surface,
    m3.text,
  ),
  Header: "flex h-16 flex-none items-center gap-2 px-3",
  HeaderText: "flex min-w-0 flex-1 flex-col",
  Overline: cn("m-0 text-xs", m3.muted),
  Title: "m-0 text-[22px] leading-7 font-normal",
  IconButton: cn(
    "flex size-10 flex-none cursor-pointer items-center justify-center rounded-full border-none bg-transparent outline-none transition-colors hover:bg-white/8 focus-visible:ring-2 focus-visible:ring-[#a8c7fa]",
    m3.muted,
  ),
  // Each step slides in from the side it comes from.
  Step: "flex min-h-0 flex-1 flex-col transition-[opacity,translate] duration-300 ease-[cubic-bezier(0.2,0,0,1)] data-[direction=back]:starting:-translate-x-6 data-[direction=back]:starting:opacity-0 data-[direction=forward]:starting:translate-x-6 data-[direction=forward]:starting:opacity-0",
  Body: "flex flex-1 flex-col items-center justify-center gap-8 overflow-y-auto px-6 pt-2 pb-6",
  Lead: cn("m-0 max-w-sm text-center text-sm leading-5", m3.muted),
  Big: "flex size-48 items-center justify-center overflow-hidden rounded-full text-7xl font-medium text-white sm:size-56",
  Actions: "flex w-full gap-3 px-6 pb-6",
  // Material buttons: tonal, filled, text.
  Tonal: cn(
    "flex h-10 flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-none px-6 text-sm font-medium outline-none transition-[background-color,box-shadow] hover:brightness-110 focus-visible:ring-2 focus-visible:ring-[#a8c7fa] disabled:cursor-default disabled:opacity-40",
    m3.containerHigh,
    m3.primaryText,
  ),
  Filled: cn(
    "relative flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full border-none px-6 text-sm font-medium outline-none transition-[filter,box-shadow] hover:shadow-[0_1px_3px_rgb(0_0_0/0.4)] hover:brightness-105 focus-visible:ring-2 focus-visible:ring-[#a8c7fa] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e1f20] disabled:cursor-default disabled:opacity-60",
    m3.primary,
  ),
  Danger:
    "flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full border-none bg-[#ffb4ab] px-6 text-sm font-medium text-[#690005] outline-none hover:brightness-105 focus-visible:ring-2 focus-visible:ring-[#ffb4ab] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e1f20]",
  Text: cn(
    "flex h-10 cursor-pointer items-center justify-center gap-2 rounded-full border-none bg-transparent px-3 text-sm font-medium outline-none transition-colors hover:bg-[#a8c7fa]/8 focus-visible:ring-2 focus-visible:ring-[#a8c7fa]",
    m3.primaryText,
  ),

  // Tabs with a sliding indicator under the active tab.
  TabList: cn("relative flex flex-none border-b px-6", m3.outline),
  Tab: cn(
    "h-12 flex-1 cursor-pointer border-none bg-transparent px-4 text-sm font-medium outline-none transition-colors hover:bg-white/5 focus-visible:bg-white/8 data-active:text-[#a8c7fa]",
    m3.muted,
  ),
  TabIndicator:
    "absolute bottom-0 left-(--active-tab-left) h-[3px] w-(--active-tab-width) rounded-t-full bg-[#a8c7fa] transition-[left,width] duration-300 ease-[cubic-bezier(0.2,0,0,1)]",
  Panel: "min-h-0 flex-1 overflow-y-auto p-6 outline-none",
  Grid: "grid grid-cols-3 gap-2",
  Photo:
    "aspect-square cursor-pointer overflow-hidden rounded-xl border-none bg-white/5 p-0 outline-none transition-opacity duration-200 hover:opacity-85 focus-visible:ring-2 focus-visible:ring-[#a8c7fa] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e1f20]",
  // The drop zone of the upload tab.
  Drop: cn(
    "flex h-full min-h-64 flex-col items-center justify-center gap-4 rounded-3xl border-2 border-dashed text-center transition-colors data-dragging:border-[#a8c7fa] data-dragging:bg-[#a8c7fa]/8",
    m3.outline,
  ),
  DropTitle: "m-0 text-base",
  Or: cn("m-0 text-xs tracking-wider uppercase", m3.muted),
  Error: "m-0 rounded-xl bg-[#ffb4ab]/10 px-3 py-2 text-xs text-[#ffb4ab]",

  // The crop step: the image on black, a square crop with a circle that
  // shows what people see, and round handles on its corners.
  CropArea: "relative mx-6 min-h-0 flex-1 overflow-hidden rounded-3xl bg-black",
  Viewport:
    "absolute inset-0 cursor-grab p-6 opacity-0 transition-opacity duration-500 data-interacting:cursor-grabbing data-loaded:opacity-100",
  Image:
    "data-transitioning:transition-transform data-transitioning:duration-(--cropper-transition-duration) data-transitioning:ease-[cubic-bezier(0.2,0,0,1)]",
  Stencil:
    "shadow-[0_0_0_9999px_rgb(0_0_0/0.6)] ring-1 ring-white/80 ring-inset outline-none transition-shadow duration-300 focus-visible:ring-2 focus-visible:ring-[#a8c7fa] data-interacting:shadow-[0_0_0_9999px_rgb(0_0_0/0.4)] data-movable:cursor-move data-transitioning:transition-[width,height,transform,box-shadow] data-transitioning:duration-(--cropper-transition-duration) data-transitioning:ease-[cubic-bezier(0.2,0,0,1)]",
  Circle:
    "pointer-events-none absolute inset-0 rounded-full border border-dashed border-white/70",
  Handle:
    "absolute z-10 size-6 rounded-full outline-none after:absolute after:inset-1 after:rounded-full after:bg-white after:shadow-[0_1px_3px_rgb(0_0_0/0.5)] after:transition-transform after:content-[''] focus-visible:after:bg-[#a8c7fa] data-resizing:after:scale-125",
  CropFooter: "flex flex-none items-center gap-2 p-6",
  Spinner:
    "size-4 animate-spin rounded-full border-2 border-[#062e6f]/30 border-t-[#062e6f]",
};

/** Where each corner handle sits, half outside the crop. */
const corners = {
  "top-left": "-top-3 -left-3 cursor-nwse-resize",
  "top-right": "-top-3 -right-3 cursor-nesw-resize",
  "bottom-right": "-right-3 -bottom-3 cursor-nwse-resize",
  "bottom-left": "-bottom-3 -left-3 cursor-nesw-resize",
} as const;

/** Whether a drag carries files. */
function hasFiles(event: React.DragEvent): boolean {
  return Array.from(event.dataTransfer.types).includes("Files");
}

/**
 * An account picture editor in the style of Google's: a dialog that walks
 * through the current picture, choosing a new one (your photos or an upload),
 * and cropping it to a circle, then saves and returns to the picture.
 */
export default function ExampleCropperAccount() {
  const [open, setOpen] = React.useState(false);
  const [picture, setPicture] = React.useState<string | null>(null);
  const [step, setStep] = React.useState<Step>("view");
  // `none` when the dialog opens: the dialog animates, not the step.
  const [direction, setDirection] = React.useState<Direction>("none");
  const [source, setSource] = React.useState<string | null>(null);

  // Object URLs made for uploads are released once replaced.
  React.useEffect(() => {
    if (!source?.startsWith("blob:")) return undefined;
    return () => URL.revokeObjectURL(source);
  }, [source]);

  function go(next: Step, nextDirection: Direction = "forward") {
    setDirection(nextDirection);
    setStep(next);
  }

  function openDialog() {
    go("view", "none");
    setOpen(true);
  }

  function choose(src: string) {
    setSource(src);
    go("crop");
  }

  return (
    <>
      <div className={local.Account}>
        <button
          type="button"
          aria-label="Change profile picture"
          className={local.AccountButton}
          onClick={openDialog}
        >
          <Picture src={picture} className="size-16 text-2xl" />
          <span className={local.AccountShade} />
          <span className={local.CameraBadge}>
            <CameraIcon />
          </span>
        </button>
        <div className={local.AccountText}>
          <p className={local.Name}>Ava Martin</p>
          <p className={local.Email}>ava.martin@example.com</p>
        </div>
        <button
          type="button"
          className={local.ManageButton}
          onClick={openDialog}
        >
          Change photo
        </button>
      </div>

      <Dialog.Root open={open} onOpenChange={setOpen}>
        <Dialog.Portal>
          <Dialog.Backdrop className={local.Backdrop} />
          <Dialog.Popup className={local.Popup}>
            {step === "view" && (
              <ViewStep
                picture={picture}
                direction={direction}
                onChange={() => go("choose")}
                onRemove={() => go("remove")}
              />
            )}
            {step === "remove" && (
              <RemoveStep
                picture={picture}
                direction={direction}
                onBack={() => go("view", "back")}
                onConfirm={() => {
                  setPicture(null);
                  go("view", "back");
                }}
              />
            )}
            {step === "choose" && (
              <ChooseStep
                direction={direction}
                onBack={() => go("view", "back")}
                onChoose={choose}
              />
            )}
            {step === "crop" && source && (
              <CropStep
                key={source}
                src={source}
                direction={direction}
                onBack={() => go("choose", "back")}
                onSave={(result) => {
                  setPicture(result);
                  go("view", "back");
                }}
              />
            )}
          </Dialog.Popup>
        </Dialog.Portal>
      </Dialog.Root>
    </>
  );
}

/** The picture, or the initial on a coloured circle. */
function Picture(props: { src: string | null; className?: string }) {
  const { src, className } = props;
  return src ? (
    <img
      src={src}
      alt=""
      className={cn("rounded-full object-cover", className)}
    />
  ) : (
    <span
      className={cn(
        "flex items-center justify-center rounded-full bg-[#8e24aa] font-medium text-white",
        className,
      )}
    >
      A
    </span>
  );
}

/** The top of each step: close or back, an overline and the title. */
function Header(props: { title: string; onBack?: (() => void) | undefined }) {
  const { title, onBack } = props;
  return (
    <div className={local.Header}>
      {onBack ? (
        <button
          type="button"
          aria-label="Back"
          className={local.IconButton}
          onClick={onBack}
        >
          <ArrowBackIcon />
        </button>
      ) : (
        <Dialog.Close aria-label="Close" className={local.IconButton}>
          <CloseIcon />
        </Dialog.Close>
      )}
      <div className={local.HeaderText}>
        <p className={local.Overline}>Account</p>
        <Dialog.Title className={local.Title}>{title}</Dialog.Title>
      </div>
    </div>
  );
}

function ViewStep(props: {
  picture: string | null;
  direction: string;
  onChange: () => void;
  onRemove: () => void;
}) {
  const { picture, direction, onChange, onRemove } = props;
  return (
    <div className={local.Step} data-direction={direction}>
      <Header title="Profile picture" />
      <div className={local.Body}>
        <Dialog.Description className={local.Lead}>
          A picture helps people recognize you and lets you know when you're
          signed in to your account.
        </Dialog.Description>
        <Picture src={picture} className={local.Big} />
      </div>
      <div className={local.Actions}>
        <button type="button" className={local.Tonal} onClick={onChange}>
          <PencilIcon /> {picture ? "Change" : "Add picture"}
        </button>
        {picture && (
          <button type="button" className={local.Tonal} onClick={onRemove}>
            <TrashIcon /> Remove
          </button>
        )}
      </div>
    </div>
  );
}

function RemoveStep(props: {
  picture: string | null;
  direction: string;
  onBack: () => void;
  onConfirm: () => void;
}) {
  const { picture, direction, onBack, onConfirm } = props;
  return (
    <div className={local.Step} data-direction={direction}>
      <Header title="Remove profile picture?" onBack={onBack} />
      <div className={local.Body}>
        <Dialog.Description className={local.Lead}>
          Your initial will show instead, on a coloured circle.
        </Dialog.Description>
        <div className="flex items-center gap-6">
          <Picture src={picture} className="size-28 text-4xl" />
          <ArrowForwardIcon className={m3.muted} />
          <Picture src={null} className="size-28 text-4xl" />
        </div>
      </div>
      <div className={cn(local.Actions, "justify-end")}>
        <button type="button" className={local.Text} onClick={onBack}>
          Cancel
        </button>
        <button type="button" className={local.Danger} onClick={onConfirm}>
          Remove
        </button>
      </div>
    </div>
  );
}

function ChooseStep(props: {
  direction: string;
  onBack: () => void;
  onChoose: (src: string) => void;
}) {
  const { direction, onBack, onChoose } = props;
  const [dragging, setDragging] = React.useState(false);
  const [error, setError] = React.useState<string | null>(null);

  function take(file: File | undefined) {
    if (!file) return;
    if (!file.type.startsWith("image/")) {
      setError(`“${file.name}” isn't an image. Choose a JPEG, PNG or WebP.`);
      return;
    }
    onChoose(URL.createObjectURL(file));
  }

  return (
    <div className={local.Step} data-direction={direction}>
      <Header title="Change profile picture" onBack={onBack} />
      <Dialog.Description className="sr-only">
        Pick one of your photos, or upload one from your device.
      </Dialog.Description>
      <Tabs.Root defaultValue="photos" className="flex min-h-0 flex-1 flex-col">
        <Tabs.List className={local.TabList}>
          <Tabs.Tab value="photos" className={local.Tab}>
            Your photos
          </Tabs.Tab>
          <Tabs.Tab value="upload" className={local.Tab}>
            Upload
          </Tabs.Tab>
          <Tabs.Indicator className={local.TabIndicator} />
        </Tabs.List>
        <Tabs.Panel value="photos" className={local.Panel}>
          <div className={local.Grid}>
            {photos.map((photo) => (
              <button
                key={photo.id}
                type="button"
                aria-label={photo.alt}
                className={local.Photo}
                onClick={() => onChoose(photo.src)}
              >
                <img
                  src={getThumbnailSrc(photo)}
                  alt=""
                  className="size-full object-cover"
                />
              </button>
            ))}
          </div>
        </Tabs.Panel>
        <Tabs.Panel value="upload" className={local.Panel}>
          <div
            className={local.Drop}
            data-dragging={dragging ? "" : undefined}
            onDragEnter={(event) => {
              if (hasFiles(event)) setDragging(true);
            }}
            onDragOver={(event) => {
              if (hasFiles(event)) event.preventDefault();
            }}
            onDragLeave={(event) => {
              if (
                !event.currentTarget.contains(
                  event.relatedTarget as Node | null,
                )
              ) {
                setDragging(false);
              }
            }}
            onDrop={(event) => {
              event.preventDefault();
              setDragging(false);
              take(event.dataTransfer.files[0]);
            }}
          >
            <ImagesIcon className={m3.primaryText} />
            <p className={local.DropTitle}>Drag a photo here</p>
            <p className={local.Or}>or</p>
            <label className={local.Filled}>
              <UploadIcon /> Upload from device
              <input
                type="file"
                accept="image/*"
                className="sr-only"
                onChange={(event) => {
                  take(event.target.files?.[0]);
                  event.target.value = "";
                }}
              />
            </label>
            {error && (
              <p role="alert" className={local.Error}>
                {error}
              </p>
            )}
          </div>
        </Tabs.Panel>
      </Tabs.Root>
    </div>
  );
}

function CropStep(props: {
  src: string;
  direction: string;
  onBack: () => void;
  onSave: (result: string) => void;
}) {
  const { src, direction, onBack, onSave } = props;
  const actionsRef = React.useRef<CropperRootActions>(null);
  const [loaded, setLoaded] = React.useState(false);
  const [saving, setSaving] = React.useState(false);

  function save() {
    const canvas = actionsRef.current?.getCanvas({ width: 512, height: 512 });
    if (!canvas) return;
    setSaving(true);
    const result = canvas.toDataURL("image/jpeg", 0.92);
    // A real app uploads it here; the demo waits a moment instead.
    window.setTimeout(() => onSave(result), 900);
  }

  return (
    <div className={local.Step} data-direction={direction}>
      <Header title="Crop & rotate" onBack={onBack} />
      <Dialog.Description className="sr-only">
        Drag the frame or its corners, and drag or zoom the photo. The circle
        shows what people see.
      </Dialog.Description>
      <Cropper.Root
        src={src}
        aspectRatio={1}
        actionsRef={actionsRef}
        disabled={saving}
        onStatusChange={(status) => setLoaded(status === "loaded")}
        className="flex min-h-[min(420px,55dvh)] flex-1 flex-col"
      >
        <div className={local.CropArea}>
          <Cropper.Viewport className={local.Viewport}>
            <Cropper.Image className={local.Image} />
            <Cropper.Stencil
              aria-label="Profile picture"
              className={local.Stencil}
            >
              <span className={local.Circle} />
              {(Object.keys(corners) as (keyof typeof corners)[]).map(
                (position) => (
                  <Cropper.Handle
                    key={position}
                    position={position}
                    className={cn(local.Handle, corners[position])}
                  />
                ),
              )}
            </Cropper.Stencil>
          </Cropper.Viewport>
        </div>
        <div className={local.CropFooter}>
          <Cropper.Rotate angle={-90} className={local.Text}>
            <RotateIcon /> Rotate
          </Cropper.Rotate>
          <Cropper.Reset className={local.Text}>Reset</Cropper.Reset>
          <button
            type="button"
            className={cn(local.Filled, "ms-auto")}
            disabled={!loaded || saving}
            onClick={save}
          >
            {saving ? (
              <>
                <span className={local.Spinner} /> Saving…
              </>
            ) : (
              "Save as profile picture"
            )}
          </button>
        </div>
      </Cropper.Root>
    </div>
  );
}

type IconProps = React.ComponentProps<"svg">;

/** A 20 px Material-style icon. */
function Icon(props: IconProps) {
  return (
    <svg
      width="20"
      height="20"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.8"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      {...props}
    />
  );
}

function CloseIcon() {
  return (
    <Icon>
      <path d="M6 6l12 12M18 6 6 18" />
    </Icon>
  );
}

function ArrowBackIcon() {
  return (
    <Icon>
      <path d="M19 12H5M11 6l-6 6 6 6" />
    </Icon>
  );
}

function ArrowForwardIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M5 12h14M13 6l6 6-6 6" />
    </Icon>
  );
}

function PencilIcon() {
  return (
    <Icon width="18" height="18">
      <path d="M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4" />
    </Icon>
  );
}

function TrashIcon() {
  return (
    <Icon width="18" height="18">
      <path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13" />
    </Icon>
  );
}

function CameraIcon() {
  return (
    <Icon width="14" height="14" strokeWidth="2.1">
      <path d="M4 8.5A2.5 2.5 0 0 1 6.5 6h1.3l1.4-2h5.6l1.4 2h1.3A2.5 2.5 0 0 1 20 8.5v8a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 16.5z" />
      <circle cx="12" cy="12.5" r="3.5" />
    </Icon>
  );
}

function RotateIcon() {
  return (
    <Icon width="18" height="18">
      <path d="M8 6.5A8 8 0 1 1 4.5 13" />
      <path d="M8 2.5v4H4" />
    </Icon>
  );
}

function UploadIcon() {
  return (
    <Icon width="18" height="18">
      <path d="M12 16V4M7 9l5-5 5 5M5 20h14" />
    </Icon>
  );
}

function ImagesIcon(props: IconProps) {
  return (
    <Icon width="48" height="48" strokeWidth="1.4" {...props}>
      <rect x="3" y="5" width="18" height="14" rx="3" />
      <circle cx="9" cy="10" r="1.6" />
      <path d="m3.5 17 5-5 4 4 3-3 5 5" />
    </Icon>
  );
}
```

```ts title="_lightbox.ts"
/** A photo of the gallery demo. The lightbox reads its `id`. */
export type Photo = {
  id: string;
  src: string;
  alt: string;
  width?: number;
  height?: number;
  caption?: string;
};

/** Photos of different shapes, served with CORS headers so they can be cropped. */
export const photos: Photo[] = [
  {
    id: "valley",
    src: "https://picsum.photos/id/1015/1600/1067",
    width: 1600,
    height: 1067,
    alt: "River valley between cliffs",
    caption: "River valley",
  },
  {
    id: "portrait",
    src: "https://picsum.photos/id/1027/1067/1600",
    width: 1067,
    height: 1600,
    alt: "Portrait of a woman",
    caption: "Portrait",
  },
  {
    id: "waterfall",
    src: "https://picsum.photos/id/1035/1600/1600",
    width: 1600,
    height: 1600,
    alt: "Waterfall in a forest",
    caption: "Waterfall",
  },
  {
    id: "panorama",
    src: "https://picsum.photos/id/1043/2400/1000",
    width: 2400,
    height: 1000,
    alt: "Panorama of a valley",
    caption: "Panorama",
  },
  {
    id: "coast",
    src: "https://picsum.photos/id/1050/1600/1067",
    width: 1600,
    height: 1067,
    alt: "Rocky coast",
    caption: "Coast",
  },
];

/** A smaller version of a photo, for thumbnails. An edited photo (a data URL) is used as is. */
export function getThumbnailSrc(photo: Photo): string {
  return photo.src.replace(
    /\/(\d+)\/(\d+)$/,
    (_, width: string, height: string) => {
      const scale = 240 / Math.max(Number(width), Number(height));
      return `/${Math.round(Number(width) * scale)}/${Math.round(Number(height) * scale)}`;
    },
  );
}

/**
 * The controls of the viewer fade and slide off the edge of the screen they
 * sit on while the photo is pulled to dismiss, and when a tap hides them or
 * the viewer opens and closes, timed with the photo's flight.
 */
const control =
  "opacity-[calc(1_-_var(--lightbox-dismiss-progress,0)*1.5)] transition-[opacity,translate] duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) in-data-swiping:transition-none in-data-controls-hidden:pointer-events-none in-data-controls-hidden:opacity-0 in-data-starting-style:opacity-0 in-data-ending-style:pointer-events-none in-data-ending-style:opacity-0 motion-reduce:translate-none";

/** Tailwind classes of the Lightbox around the cropper. */
export const viewer = {
  // Triggers. While the viewer shows a trigger's photo, hide the trigger but
  // keep its space: the photo looks like it left its slot.
  trigger:
    "group relative cursor-pointer overflow-hidden border-none bg-white/5 p-0 data-flying:invisible data-popup-open:invisible focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  triggerImage:
    "size-full object-cover transition-opacity duration-300 group-data-pending:opacity-60",

  backdrop:
    "fixed inset-0 bg-[rgb(8_8_12/calc(0.92*(1_-_var(--lightbox-dismiss-progress,0))))] backdrop-blur-[calc(20px*(1_-_var(--lightbox-dismiss-progress,0)))] transition-[background-color,backdrop-filter] duration-(--lightbox-flight-duration) ease-(--lightbox-flight-easing) data-swiping:transition-none data-starting-style:bg-transparent data-starting-style:backdrop-blur-none data-ending-style:bg-transparent data-ending-style:backdrop-blur-none",
  popup: "fixed inset-0 text-white select-none outline-none",
  viewport:
    "absolute inset-0 cursor-default gap-x-4 select-none in-data-zoomed:cursor-grab in-data-zoomed:data-swiping:cursor-grabbing",

  /** A control at the top: it slides up and away. */
  controlTop: `${control} -translate-y-[calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0))] in-data-controls-hidden:-translate-y-4 in-data-starting-style:-translate-y-4 in-data-ending-style:-translate-y-[max(1rem,calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0)))]`,
  /** A control at the bottom: it slides down and away. */
  controlBottom: `${control} translate-y-[calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0))] in-data-controls-hidden:translate-y-4 in-data-starting-style:translate-y-4 in-data-ending-style:translate-y-[max(1rem,calc((100%+1rem)*1.5*var(--lightbox-dismiss-progress,0)))]`,

  iconButton:
    "flex size-10 cursor-pointer items-center justify-center rounded-full border-none bg-white/10 text-white backdrop-blur-md transition-colors hover:not-data-disabled:bg-white/20 data-disabled:cursor-default data-disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-white",
  counter:
    "flex h-10 items-center px-2.5 text-sm font-medium whitespace-nowrap text-white/90 tabular-nums",

  // The caption slides in from the side the photo came from.
  enter:
    "transition-[opacity,translate] duration-300 ease-out data-starting-style:opacity-0 data-starting-style:transition-none data-starting-style:data-[activation-direction=left]:-translate-x-5 data-starting-style:data-[activation-direction=right]:translate-x-5 motion-reduce:transition-none",

  // The thumbnail strip scrolls without a scrollbar, fading at its ends, with
  // a ring that glides to the active thumbnail.
  strip:
    "relative flex max-w-full gap-2 overflow-x-auto px-[50%] py-1.5 [scrollbar-width:none] data-overflow-end:mask-r-from-85% data-overflow-start:mask-l-from-85% [&::-webkit-scrollbar]:hidden",
  indicator:
    "pointer-events-none absolute top-(--active-thumbnail-top) left-(--active-thumbnail-left) h-(--active-thumbnail-height) w-(--active-thumbnail-width) rounded-lg outline-2 outline-offset-2 outline-white",
};
```

### In a lightbox

Open a photo and press **Edit** to crop it full screen without leaving the viewer, like a phone's gallery: frosted bars over the photo, aspect presets, a zoom dial, rotate, flip and reset, with the crop's size in pixels as you drag. **Done** puts the cropped photo in the gallery, and it flies back to its tile; editing it again starts from the same crop, **Revert** brings the original back, and the cropped photo can be downloaded.

The editor is a `Cropper` laid over a [Lightbox](/docs/components/lightbox) viewer inside `Lightbox.Popup`, so the dialog keeps focus in it. It keeps its keys to itself: the arrows move the crop instead of the photo, and Esc cancels the edit instead of closing the viewer.

```tsx title="lightbox.tsx"
"use client";
import * as React from "react";
import { Lightbox, type LightboxItemValue } from "@logic-ui/react/lightbox";
import {
  Cropper,
  type CropperCoordinates,
  type CropperRootActions,
  type CropperState,
} from "@logic-ui/react/cropper";
import { cn } from "cn";
import { handleClasses, handlePositions } from "./_classes";
import { getThumbnailSrc, photos, type Photo, viewer } from "./_lightbox";
import { ZoomDial } from "./_zoom-dial";

/** A crop saved with a photo, to start from it the next time it is edited. */
type SavedCrop = Pick<CropperState, "transforms"> & {
  coordinates: CropperCoordinates;
  visibleArea: CropperCoordinates;
  ratio: RatioKey;
};

/** The cropped image of a photo, with the crop that made it. */
type Edit = { src: string; width: number; height: number; crop: SavedCrop };

type GalleryPhoto = Photo & { edited: boolean };

const ratios = {
  original: "Original",
  free: "Free",
  square: "1:1",
  portrait: "4:5",
  wide: "16:9",
} as const;
type RatioKey = keyof typeof ratios;

function getRatio(key: RatioKey, photo: Photo): number | undefined {
  switch (key) {
    case "original":
      return photo.width && photo.height
        ? photo.width / photo.height
        : undefined;
    case "square":
      return 1;
    case "portrait":
      return 4 / 5;
    case "wide":
      return 16 / 9;
    default:
      return undefined;
  }
}

/** Tailwind classes of this demo. */
const local = {
  Gallery: "flex w-full max-w-xl flex-col gap-3 select-none",
  Header: "flex items-baseline justify-between",
  Heading: "m-0 text-sm font-semibold text-white",
  Hint: "m-0 text-xs text-neutral-500",
  Grid: "grid grid-cols-3 gap-1.5 sm:grid-cols-5",
  Tile: "aspect-square rounded-xl",
  // A dot on the tiles of edited photos.
  EditedDot:
    "absolute top-2 right-2 size-2 rounded-full bg-[#ffd60a] shadow-[0_0_0_2px_rgb(0_0_0/0.5)]",
  Actions: "ms-auto flex items-center gap-2",
  Pill: "flex h-10 cursor-pointer items-center gap-2 rounded-full border-none bg-white/10 px-4 text-sm font-medium text-white backdrop-blur-md transition-colors no-underline hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white",
  EditedBadge:
    "rounded-full bg-[#ffd60a]/15 px-2 py-0.5 text-[11px] font-semibold tracking-wide text-[#ffd60a] uppercase",
  Caption: `m-0 flex items-center gap-2 text-[0.9375rem] font-semibold ${viewer.enter}`,
  Strip: viewer.strip,
  Thumbnail:
    "relative size-12 flex-none cursor-pointer overflow-hidden rounded-lg border-none bg-white/10 p-0 opacity-50 transition-opacity duration-300 hover:opacity-80 data-active:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
  Ring: viewer.indicator,

  // Frosted bars over the photo: a zoomed photo shows through them, blurred.
  // The bars have fixed heights, and the photo at rest fills exactly the
  // room between them, edge to edge: it only goes under them when zoomed.
  // The editor inherits the same heights, so its bars match.
  Popup: `${viewer.popup} [--bar-top:calc(4rem+env(safe-area-inset-top))] [--bar-bottom:calc(9.5rem+env(safe-area-inset-bottom))]`,
  Slide: "pt-(--bar-top) pb-(--bar-bottom)",
  TopBar:
    "absolute inset-x-0 top-0 flex h-(--bar-top) items-center justify-between gap-3 border-b border-white/10 bg-neutral-950/50 px-[max(0.75rem,env(safe-area-inset-left))] pt-[env(safe-area-inset-top)] backdrop-blur-xl backdrop-saturate-150",
  BottomBar:
    "absolute inset-x-0 bottom-0 flex h-(--bar-bottom) flex-col items-center justify-center gap-3 border-t border-white/10 bg-neutral-950/50 px-3 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl backdrop-saturate-150",

  // The editor covers the viewer, like a phone's photo editor.
  // The cropper fills the editor, and its bars float over it, frosted. The
  // viewport's padding keeps the crop clear of them, while a zoomed photo
  // still runs under them.
  Editor:
    "absolute inset-0 z-10 bg-black text-white outline-none transition-opacity duration-300 starting:opacity-0",
  EditorBar:
    "absolute inset-x-0 top-0 z-20 flex h-(--bar-top) items-center justify-between border-b border-white/10 bg-neutral-950/50 backdrop-blur-xl backdrop-saturate-150 px-5 pt-[env(safe-area-inset-top)]",
  EditorTitle:
    "m-0 flex flex-col items-center text-[11px] font-semibold tracking-[0.2em] text-white/60 uppercase [&_span]:font-mono [&_span]:tracking-normal [&_span]:text-white/40 [&_span]:normal-case",
  TextButton:
    "-mx-2 cursor-pointer rounded-full border-none bg-transparent px-2 py-1 text-[15px] text-white transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-white disabled:cursor-default disabled:opacity-30",
  DoneButton:
    "-mx-2 cursor-pointer rounded-full border-none bg-transparent px-2 py-1 text-[15px] font-semibold text-[#ffd60a] transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-[#ffd60a]",
  Viewport:
    "absolute inset-0 pt-(--bar-top) pb-(--bar-bottom) cursor-grab bg-black opacity-0 transition-opacity duration-500 data-interacting:cursor-grabbing data-loaded:opacity-100",
  Image:
    "data-transitioning:transition-transform data-transitioning:duration-(--cropper-transition-duration) data-transitioning:ease-[cubic-bezier(0.32,0.72,0,1)]",
  Stencil:
    "shadow-[0_0_0_9999px_rgb(0_0_0/0.65)] ring-1 ring-white/70 ring-inset outline-none transition-shadow duration-300 focus-visible:ring-2 focus-visible:ring-[#ffd60a] data-interacting:shadow-[0_0_0_9999px_rgb(0_0_0/0.35)] data-movable:cursor-move data-transitioning:transition-[width,height,transform,box-shadow] data-transitioning:duration-(--cropper-transition-duration) data-transitioning:ease-[cubic-bezier(0.32,0.72,0,1)]",
  Guides:
    "pointer-events-none absolute inset-0 grid grid-cols-[repeat(var(--cropper-grid-columns),minmax(0,1fr))] grid-rows-[repeat(var(--cropper-grid-rows),minmax(0,1fr))] opacity-0 transition-opacity duration-300 data-interacting:opacity-100 *:border-white/40 *:not-data-[column='2']:border-r *:not-data-[row='2']:border-b",
  Controls:
    "absolute inset-x-0 bottom-0 z-20 flex h-(--bar-bottom) flex-col justify-center gap-2 border-t border-white/10 bg-neutral-950/50 backdrop-blur-xl backdrop-saturate-150 pb-[env(safe-area-inset-bottom)]",
  Chips: "flex justify-center gap-2 overflow-x-auto px-4",
  Chip: "h-7 flex-none cursor-pointer rounded-full border-none bg-white/10 px-3 text-xs font-medium text-white/80 transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-pressed:bg-white aria-pressed:text-black",
  Tools: "flex items-center justify-center gap-6 px-6",
  Tool: "flex size-11 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-white/90 transition-[background-color,opacity,scale] hover:bg-white/10 active:scale-90 focus-visible:outline-2 focus-visible:outline-white disabled:cursor-default disabled:opacity-30 disabled:hover:bg-transparent",
};

const gallery = photos;

/**
 * A gallery that edits in place: open a photo, press Edit, and crop it full
 * screen without leaving the viewer. Saving puts the cropped photo in the
 * gallery, and the image flies back to its tile; editing it again starts
 * from the same crop, and Revert brings the original back.
 */
export default function ExampleCropperLightbox() {
  const [edits, setEdits] = React.useState<Record<string, Edit>>({});
  const [open, setOpen] = React.useState(false);
  const [value, setValue] = React.useState<LightboxItemValue>(gallery[0]!.id);
  const [editing, setEditing] = React.useState(false);

  const items: GalleryPhoto[] = gallery.map((photo) => {
    const edit = edits[photo.id];
    return edit
      ? {
          ...photo,
          src: edit.src,
          width: edit.width,
          height: edit.height,
          edited: true,
        }
      : { ...photo, edited: false };
  });
  const original = gallery.find((photo) => photo.id === value) ?? gallery[0]!;
  const edit = edits[original.id];

  const revert = () =>
    setEdits((current) => {
      const next = { ...current };
      delete next[original.id];
      return next;
    });

  return (
    <section className={local.Gallery}>
      <div className={local.Header}>
        <h3 className={local.Heading}>Recents</h3>
        <p className={local.Hint}>Open a photo, then Edit</p>
      </div>

      <Lightbox.Root
        items={items}
        open={open}
        onOpenChange={(nextOpen) => {
          setOpen(nextOpen);
          if (!nextOpen) setEditing(false);
        }}
        value={value}
        onValueChange={setValue}
      >
        <div className={local.Grid}>
          {items.map((photo) => (
            <Lightbox.Trigger
              key={photo.id}
              value={photo.id}
              className={cn(viewer.trigger, local.Tile)}
            >
              <img
                loading="lazy"
                src={getThumbnailSrc(photo)}
                alt=""
                className={viewer.triggerImage}
              />
              {photo.edited && <span className={local.EditedDot} />}
            </Lightbox.Trigger>
          ))}
        </div>

        <Lightbox.Portal>
          <Lightbox.Backdrop className={viewer.backdrop} />
          <Lightbox.Popup className={local.Popup}>
            <Lightbox.Viewport className={viewer.viewport}>
              {(photo: GalleryPhoto) => (
                <Lightbox.Slide className={local.Slide}>
                  <Lightbox.Media width={photo.width} height={photo.height}>
                    <img src={photo.src} alt={photo.alt} />
                  </Lightbox.Media>
                </Lightbox.Slide>
              )}
            </Lightbox.Viewport>

            <header className={cn(local.TopBar, viewer.controlTop)}>
              <Lightbox.Counter className={viewer.counter} />
              <div className={local.Actions}>
                {edit && (
                  <>
                    <a
                      href={edit.src}
                      download={`${original.id}-cropped.jpg`}
                      aria-label="Download the cropped photo"
                      className={local.Pill}
                    >
                      <DownloadIcon />
                    </a>
                    <button
                      type="button"
                      className={local.Pill}
                      onClick={revert}
                    >
                      <UndoIcon /> Revert
                    </button>
                  </>
                )}
                <button
                  type="button"
                  className={local.Pill}
                  onClick={() => setEditing(true)}
                >
                  <CropIcon /> Edit
                </button>
                <Lightbox.Close className={viewer.iconButton}>
                  <CloseIcon />
                </Lightbox.Close>
              </div>
            </header>

            <footer className={cn(local.BottomBar, viewer.controlBottom)}>
              <Lightbox.Title className={local.Caption}>
                {(photo: GalleryPhoto) => (
                  <>
                    {photo.caption ?? "Untitled"}
                    {photo.edited && (
                      <span className={local.EditedBadge}>Edited</span>
                    )}
                  </>
                )}
              </Lightbox.Title>
              <Lightbox.ThumbnailList className={local.Strip}>
                {(photo: GalleryPhoto) => (
                  <Lightbox.Thumbnail
                    aria-label={photo.alt}
                    className={local.Thumbnail}
                  >
                    <img
                      src={getThumbnailSrc(photo)}
                      alt=""
                      className="size-full object-cover"
                    />
                  </Lightbox.Thumbnail>
                )}
                <Lightbox.ThumbnailIndicator className={local.Ring} />
              </Lightbox.ThumbnailList>
            </footer>

            {editing && (
              <Editor
                key={original.id}
                photo={original}
                saved={edit?.crop}
                onCancel={() => setEditing(false)}
                onDone={(next) => {
                  setEdits((current) => ({ ...current, [original.id]: next }));
                  setEditing(false);
                }}
              />
            )}
          </Lightbox.Popup>
        </Lightbox.Portal>
      </Lightbox.Root>
    </section>
  );
}

/**
 * The crop editor, over the viewer. It crops the original photo, starting
 * from the last saved crop. Its keys stay its own: the arrows move the crop
 * rather than the viewer's photo, and Esc cancels the edit rather than
 * closing the viewer.
 */
function Editor(props: {
  photo: Photo;
  saved: SavedCrop | undefined;
  onCancel: () => void;
  onDone: (edit: Edit) => void;
}) {
  const { photo, saved, onCancel, onDone } = props;
  const actionsRef = React.useRef<CropperRootActions>(null);
  const [ratio, setRatio] = React.useState<RatioKey>(saved?.ratio ?? "free");
  const [size, setSize] = React.useState<CropperCoordinates | null>(null);

  function done() {
    const actions = actionsRef.current;
    const state = actions?.getState();
    const canvas = actions?.getCanvas({ maxWidth: 2000, maxHeight: 2000 });
    if (!state?.coordinates || !state.visibleArea || !canvas) return;
    onDone({
      src: canvas.toDataURL("image/jpeg", 0.92),
      width: canvas.width,
      height: canvas.height,
      crop: {
        coordinates: state.coordinates,
        visibleArea: state.visibleArea,
        transforms: state.transforms,
        ratio,
      },
    });
  }

  return (
    <div
      ref={focusOnMount}
      role="group"
      aria-label="Edit photo"
      tabIndex={-1}
      className={local.Editor}
      onKeyDown={(event) => {
        if (event.key === "Escape") onCancel();
        event.stopPropagation();
      }}
    >
      <Cropper.Root
        src={photo.src}
        aspectRatio={getRatio(ratio, photo)}
        defaultCoordinates={saved?.coordinates}
        defaultVisibleArea={saved?.visibleArea}
        defaultTransforms={saved?.transforms}
        actionsRef={actionsRef}
        onCoordinatesChange={setSize}
        className="absolute inset-0"
      >
        <div className={local.EditorBar}>
          <button type="button" className={local.TextButton} onClick={onCancel}>
            Cancel
          </button>
          <p className={local.EditorTitle}>
            Crop
            <span>{size ? `${size.width} × ${size.height}` : "…"}</span>
          </p>
          <button type="button" className={local.DoneButton} onClick={done}>
            Done
          </button>
        </div>

        <Cropper.Viewport className={local.Viewport}>
          <Cropper.Image className={local.Image} />
          <Cropper.Stencil className={local.Stencil}>
            <Cropper.Grid className={local.Guides} />
            {handlePositions.map((position) => (
              <Cropper.Handle
                key={position}
                position={position}
                className={handleClasses[position]}
              />
            ))}
          </Cropper.Stencil>
        </Cropper.Viewport>

        <div className={local.Controls}>
          <ZoomDial />
          <div className={local.Chips}>
            {(Object.keys(ratios) as RatioKey[]).map((key) => (
              <button
                key={key}
                type="button"
                aria-pressed={ratio === key}
                onClick={() => setRatio(key)}
                className={local.Chip}
              >
                {ratios[key]}
              </button>
            ))}
          </div>
          <div className={local.Tools}>
            <Cropper.Rotate angle={-90} className={local.Tool}>
              <RotateIcon />
            </Cropper.Rotate>
            <Cropper.Flip className={local.Tool}>
              <FlipIcon />
            </Cropper.Flip>
            <Cropper.Flip orientation="vertical" className={local.Tool}>
              <FlipIcon className="rotate-90" />
            </Cropper.Flip>
            <Cropper.Reset className={local.Tool}>
              <UndoIcon />
            </Cropper.Reset>
          </div>
        </div>
      </Cropper.Root>
    </div>
  );
}

/** Moves focus into the editor, so its keys reach it from the start. */
function focusOnMount(element: HTMLDivElement | null) {
  element?.focus();
}

type IconProps = React.ComponentProps<"svg">;

function Icon(props: IconProps) {
  return (
    <svg
      width="20"
      height="20"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      {...props}
    />
  );
}

function CropIcon(props: IconProps) {
  return (
    <Icon width="16" height="16" {...props}>
      <path d="M6 2v14a2 2 0 0 0 2 2h14" />
      <path d="M2 6h14a2 2 0 0 1 2 2v14" />
    </Icon>
  );
}

function UndoIcon(props: IconProps) {
  return (
    <Icon width="16" height="16" {...props}>
      <path d="M4 12a8 8 0 1 0 2.4-5.7" />
      <path d="M4 4v4.5h4.5" />
    </Icon>
  );
}

function CloseIcon(props: IconProps) {
  return (
    <Icon width="16" height="16" {...props}>
      <path d="M6 6l12 12M18 6 6 18" />
    </Icon>
  );
}

function DownloadIcon(props: IconProps) {
  return (
    <Icon width="16" height="16" {...props}>
      <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />
    </Icon>
  );
}

function RotateIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <rect x="4" y="10" width="10" height="10" rx="1.5" />
      <path d="M8 6.5A8 8 0 0 1 19.5 13" />
      <path d="m5.5 5 2.5 1.5L6.5 9" />
    </Icon>
  );
}

function FlipIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M12 3v18" strokeDasharray="2 2.5" />
      <path d="M9 7 3.5 17H9zM15 7l5.5 10H15z" />
    </Icon>
  );
}
```

```tsx title="Edit the photo shown, inside the popup"
<Lightbox.Popup>
  <Lightbox.Viewport>…</Lightbox.Viewport>
  <button onClick={() => setEditing(true)}>Edit</button>
  {editing && (
    // Keys pressed in the editor stay there: Esc cancels the edit.
    <div
      className="absolute inset-0 bg-black"
      onKeyDown={(event) => {
        if (event.key === "Escape") setEditing(false);
        event.stopPropagation();
      }}
    >
      <Cropper.Root src={original.src} actionsRef={actionsRef}>
        …
      </Cropper.Root>
    </div>
  )}
</Lightbox.Popup>
```

Saving replaces the item in `items` with the cropped image (`getCanvas().toDataURL()`) and its new `width` and `height`; the item keeps its value, so the viewer stays on it.

### Restoring a crop

`defaultCoordinates` sets the first crop in image pixels; a size without `left` and `top` is centered. `defaultVisibleArea` sets the first view, and `defaultTransforms` the rotation and flip. To restore a crop, save `getState()` and pass its `coordinates`, `visibleArea` and `transforms` back as these defaults: they fit the viewport whatever its size.

### Preview

`Cropper.Preview` shows the crop live, scaled to fit it. Give it the crop's shape with `aspect-(--cropper-aspect-ratio)`, set on `Cropper.Root`.

```tsx title="A live preview"
<Cropper.Preview className="aspect-(--cropper-aspect-ratio) w-40 rounded" />
```

### Animations

Buttons, keys and the end of a gesture move the crop and the image to their new place at once, and set `data-transitioning` on the parts for `transitionDuration` (350 ms). Animate them with it and `--cropper-transition-duration`; drags wait for the animation to end. Set `transitionDuration={0}` to turn it off.

```tsx title="Slide to the new place"
<Cropper.Image className="data-transitioning:transition-transform data-transitioning:duration-(--cropper-transition-duration)" />
<Cropper.Stencil className="data-transitioning:transition-[width,height,transform] data-transitioning:duration-(--cropper-transition-duration)" />
```

### Styling

Every part is unstyled. Style the cropper with Tailwind (or any CSS), using the parts' data attributes and CSS variables:

- `Cropper.Root`, `Cropper.Viewport` and `Cropper.Preview` have `data-loading`, `data-loaded` and `data-error`. Fade the viewport in with `data-loaded`, and show a message with `data-error`.
- `Cropper.Root` has `--cropper-image-aspect-ratio`, to give `Cropper.Viewport` the image's shape (`aspect-(--cropper-image-aspect-ratio) max-h-96`), and `--cropper-aspect-ratio`, the crop's.
- `Cropper.Viewport` clips its content and handles touch itself (`overflow: hidden`, `touch-action: none`). It has `data-interacting` during a drag, a pinch or a wheel zoom: `data-interacting:cursor-grabbing`.
- `Cropper.Viewport`'s padding is room the crop and the fitted image stay out of, while a zoomed or moved image still shows there. Make it full screen and pad it by the height of bars laid over it: the photo runs under them, as in the [lightbox example](#in-a-lightbox), where the bars are frosted with `bg-neutral-950/50 backdrop-blur-xl`.
- `Cropper.Stencil` is placed with `transform`, `width` and `height`. Darken the image around it with a large shadow, `shadow-[0_0_0_9999px_rgb(0_0_0/0.6)]`, and shape it with `rounded-full`. It has `data-movable`, `data-moving` and `data-resizing`.
- `Cropper.Grid` renders `rows` × `columns` cells with `data-row` and `data-column`; lay them out with `grid-cols-[repeat(var(--cropper-grid-columns),minmax(0,1fr))]` and the same for rows (the variables hold counts, so they need `repeat()`) and show them with `data-interacting`.
- `Cropper.Handle` has `data-position` and `data-resizing` while dragged.

### Keyboard

`Cropper.Stencil` and every `Cropper.Handle` are focusable:

- On the crop, the arrow keys move it by 10 px on screen, 50 with Shift, and + and − zoom.
- On a handle, the arrow keys move its corner or edge, 50 px with Shift.

## API reference

### Cropper.Root

Holds the image, the crop and the view, and provides them to the parts.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| src | `string \| null` | - | URL of the image to crop. |
| crossOrigin | `"anonymous" \| "use-credentials"` | `"anonymous"` | CORS mode of the image. `getCanvas()` needs it for an image from another origin, and the server must allow it; `null` loads the image without CORS. |
| aspectRatio | `CropperAspectRatio` | - | Aspect ratio (width / height) of the crop: a number, or a range. |
| minWidth | `number` | - | Smallest width of the crop, in image pixels. |
| minHeight | `number` | - | Smallest height of the crop, in image pixels. |
| maxWidth | `number` | - | Largest width of the crop, in image pixels. |
| maxHeight | `number` | - | Largest height of the crop, in image pixels. |
| stencilSize | `CropperSize \| ((state: CropperState) => CropperSize)` | - | Fixes the crop's size on screen (pixels): the image moves and zooms behind it, and the crop can't be moved or resized. It keeps within `aspectRatio`. |
| imageRestriction | `"fill-area" \| "fit-area" \| "stencil" \| "none"` | `"fit-area"` | Where the image may go relative to the crop and the viewport. `"fill-area"`: the image always fills the viewport, and the crop stays on the image. `"fit-area"`: the image can't be zoomed out past the viewport on both axes, and the crop stays on the image. `"stencil"`: only the crop stays on the image; the view is free. `"none"`: no limit; the crop can leave the image. |
| adjustStencil | `boolean` | `true` | Whether zooming resizes the crop with the image, rather than keeping its size on screen. Ignored with `stencilSize`. |
| defaultCoordinates | `Partial<CropperCoordinates> \| ((state: CropperState) => Partial<CropperCoordinates>)` | - | The crop to start from, in image pixels. A size without `left` and `top` is centered. 80% of the image by default. |
| defaultVisibleArea | `CropperCoordinates \| ((state: CropperState) => CropperCoordinates)` | - | The part of the image to show first, in image pixels. Wins over `defaultCoordinates`. |
| defaultTransforms | `CropperDefaultTransforms` | - | The rotation and flip to start from. |
| transitionDuration | `number` | `350` | How long a change from a button, a key or the end of a gesture animates (ms); drags wait for it. Animate with `data-transitioning` and `--cropper-transition-duration`. `0` turns it off. |
| disabled | `boolean` | `false` | Whether the cropper ignores the user. |
| onCoordinatesChange | `( coordinates: CropperCoordinates \| null, eventDetails: CropperRootChangeEventDetails, ) => void` | - | Called when the crop, in whole image pixels, changes. |
| onCoordinatesCommitted | `( coordinates: CropperCoordinates \| null, eventDetails: CropperRootChangeEventDetails, ) => void` | - | Called with the crop when a change is complete: at the end of a gesture, or after a button or key press. |
| onStatusChange | `(status: CropperRootStatus) => void` | - | Called when `status` changes: the image loads, shows or fails. |
| actionsRef | `React.RefObject<CropperRootActions \| null>` | - | Receives the actions: `getCanvas()`, `getCoordinates()`, `rotate()` … |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-loading` | Present while the image loads, or waits for `Cropper.Viewport` to have a size. |
| `data-loaded` | Present once the image is on screen with its crop. |
| `data-error` | Present when the image failed to load. |
| `data-disabled` | Present when the cropper ignores the user. |
| `data-interacting` | Present while a drag, pinch or wheel zoom is in progress. |
| `data-transitioning` | Present while a change animates, for `--cropper-transition-duration`. |

| CSS variable | Description |
| --- | --- |
| `--cropper-image-aspect-ratio` | Natural aspect ratio of the image, to size `Cropper.Viewport` to it. |
| `--cropper-aspect-ratio` | Aspect ratio of the crop, to size `Cropper.Preview` to it. |
| `--cropper-transition-duration` | The `transitionDuration` prop, for the transitions of `data-transitioning`. |

### Cropper.Viewport

The area that shows the image and the crop. Its size on screen is the
cropper's: give it one, such as `aspect-(--cropper-image-aspect-ratio)`.
Its padding is room the crop and the fitted image stay out of, where a
zoomed or moved image still shows: under bars laid over it, for example.
Drags, pinches and the wheel on it move and zoom the image.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| zoomOnWheel | `boolean` | `true` | Whether the wheel and a trackpad pinch zoom the image. |
| zoomOnPinch | `boolean` | `true` | Whether two fingers pinch to zoom the image. |
| rotateOnPinch | `boolean` | `false` | Whether two fingers turn the image. |
| panOnDrag | `boolean` | `true` | Whether dragging the image moves it. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-loading` | Present while the image loads, or waits for `Cropper.Viewport` to have a size. |
| `data-loaded` | Present once the image is on screen with its crop. |
| `data-error` | Present when the image failed to load. |
| `data-disabled` | Present when the cropper ignores the user. |
| `data-interacting` | Present while a drag, pinch or wheel zoom is in progress. |
| `data-transitioning` | Present while a change animates, for `--cropper-transition-duration`. |

### Cropper.Image

The image, placed under the crop as the view moves and zooms. Renders
nothing until the image is on screen. Its `alt` is empty unless you set one.
Renders an `<img>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-transitioning` | Present while a change animates, for `--cropper-transition-duration`. |

### Cropper.Stencil

The crop, placed over the image in `Cropper.Viewport`. Drag it or use the
arrow keys to move it (Shift for larger steps), and + and − to zoom.
Style the area outside it with a large shadow, and its shape with
`border-radius`. Renders nothing until the image is on screen.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| movable | `boolean` | `true` | Whether the crop can be dragged. A drag on a fixed crop moves the image. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-movable` | Present when the crop can be dragged and moved with the arrow keys. |
| `data-moving` | Present while the crop is dragged. |
| `data-resizing` | Present while a handle is dragged. |
| `data-interacting` | Present while a drag, pinch or wheel zoom is in progress. |
| `data-transitioning` | Present while a change animates, for `--cropper-transition-duration`. |
| `data-disabled` | Present when the cropper ignores the user. |

### Cropper.Handle

Resizes the crop from a corner or an edge: drag it, or use the arrow
keys (Shift for larger steps). Hold Shift while dragging to keep the
aspect ratio. Place it in `Cropper.Stencil` and position it with
`data-position`; an edge handle can be styled as a line along the edge.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| position (required) | `"top-left" \| "top" \| "top-right" \| "right" \| "bottom-right" \| "bottom" \| "bottom-left" \| "left"` | - | The corner or edge of the crop it resizes. |
| disabled | `boolean` | `false` | Whether it can't resize the crop. Handles are disabled with `stencilSize` too. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-position` | The corner or edge the handle resizes: `top-left`, `top`, `top-right`, `right`, `bottom-right`, `bottom`, `bottom-left` or `left`. |
| `data-resizing` | Present while the handle is dragged. |
| `data-disabled` | Present when the handle can't resize the crop. |

### Cropper.Grid

Guides over the crop, such as the rule of thirds: renders `rows` ×
`columns` cells, each a `<div>` with `data-row` and `data-column`. Lay
them out with `repeat(var(--cropper-grid-columns), 1fr)` and the same for
rows, and show them with `data-interacting`. Hidden from assistive
technology.
Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| rows | `number` | `3` | Number of rows. |
| columns | `number` | `3` | Number of columns. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-interacting` | Present while a drag, pinch or wheel zoom is in progress: show the grid then. |
| `data-row` | On each cell: its row, from 0. |
| `data-column` | On each cell: its column, from 0. |

| CSS variable | Description |
| --- | --- |
| `--cropper-grid-rows` | The `rows` prop, a count: `grid-template-rows: repeat(var(--cropper-grid-rows), 1fr)`. |
| `--cropper-grid-columns` | The `columns` prop, a count: `grid-template-columns: repeat(var(--cropper-grid-columns), 1fr)`. |

### Cropper.Preview

Shows the cropped part of the image, live, scaled to fit the element.
Give it the crop's ratio with `aspect-(--cropper-aspect-ratio)`; with
another ratio, the crop is centered and the image shows around it.
Renders a `<div>` element with an `<img>` inside.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-loading` | Present while the image loads, or waits for `Cropper.Viewport` to have a size. |
| `data-loaded` | Present once the image is on screen with its crop. |
| `data-error` | Present when the image failed to load. |
| `data-transitioning` | Present while a change animates, for `--cropper-transition-duration`. |

### Cropper.ZoomIn

Zooms the image in by one step. Disabled at the closest view.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Cropper.ZoomOut

Zooms the image out by one step. Disabled at the widest view.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Cropper.Rotate

Turns the image by `angle` degrees.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| angle | `number` | `90` | Degrees to turn the image by, clockwise; negative turns it counterclockwise. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Cropper.Flip

Mirrors the image.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| orientation | `"horizontal" \| "vertical"` | `"horizontal"` | The axis the image is mirrored across. `"horizontal"`: left and right swap. `"vertical"`: top and bottom swap. |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### Cropper.Reset

Starts over with the default crop, rotation and view.
Renders a `<button>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | `string` | - | CSS class applied to the element. |
| style | `React.CSSProperties` | - | Style applied to the element. |
| render | `React.ReactElement \| ((props, state) => React.ReactElement)` | - | Replaces the element with another tag or component, or composes it with one. Takes an element, or a function that receives the props and the state. |

| Data attribute | Description |
| --- | --- |
| `data-disabled` | Present when the action can't run now. |

### CropperFlipOrientation

The axis the image is mirrored across, as seen on screen:

| Value | Description |
| --- | --- |
| `"horizontal"` | left and right swap. |
| `"vertical"` | top and bottom swap. |

### CropperHandlePosition

The corner or edge of the crop a handle resizes:
- `"top-left"`, `"top-right"`, `"bottom-right"`, `"bottom-left"`: a corner, both axes.
- `"top"`, `"right"`, `"bottom"`, `"left"`: an edge, one axis.

| Value | Description |
| --- | --- |
| `"top-left"` |  |
| `"top"` |  |
| `"top-right"` |  |
| `"right"` |  |
| `"bottom-right"` |  |
| `"bottom"` |  |
| `"bottom-left"` |  |
| `"left"` |  |

### CropperRootChangeEventDetails

Details of a change: its `reason` and the native `event`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| reason (required) | `"none" \| "image-load" \| "viewport-resize" \| "settings-change" \| "stencil-drag" \| "handle-drag" \| "image-drag" \| "pinch" \| "wheel" \| "keyboard" \| "zoom-in-press" \| "zoom-out-press" \| "rotate-press" \| "flip-press" \| "reset-press" \| "imperative-action"` | - | The reason for the event. |
| event (required) | `ReasonToEvent<Reason>` | - | The native event associated with the custom event. |

### CropperRootChangeEventReason

Why the crop changed, as passed in `eventDetails.reason`:

| Value | Description |
| --- | --- |
| `"none"` | Another cause. |
| `"image-load"` | A new image was shown, or the cropper started over on it. |
| `"viewport-resize"` | `Cropper.Viewport` changed size. |
| `"settings-change"` | A prop such as `aspectRatio` changed and the crop was fitted to it. |
| `"stencil-drag"` | `Cropper.Stencil` was dragged. |
| `"handle-drag"` | A `Cropper.Handle` was dragged. |
| `"image-drag"` | The image was dragged. |
| `"pinch"` | Two fingers pinched or turned the image. |
| `"wheel"` | The wheel or a trackpad pinch zoomed the image. |
| `"keyboard"` | A key moved, resized or zoomed the crop. |
| `"zoom-in-press"` | `Cropper.ZoomIn` was pressed. |
| `"zoom-out-press"` | `Cropper.ZoomOut` was pressed. |
| `"rotate-press"` | `Cropper.Rotate` was pressed. |
| `"flip-press"` | `Cropper.Flip` was pressed. |
| `"reset-press"` | `Cropper.Reset` was pressed. |
| `"imperative-action"` | A call on `actionsRef` or on the context's actions. |

### CropperRootImageRestriction

Where the image may go relative to the crop and the viewport:

| Value | Description |
| --- | --- |
| `"fill-area"` | the image always fills the viewport, and the crop stays on the image. |
| `"fit-area"` | the image can't be zoomed out past the viewport on both axes, and the crop stays on the image. |
| `"stencil"` | only the crop stays on the image; the view is free. |
| `"none"` | no limit; the crop can leave the image. |

### CropperRootStatus

Where the image is:

| Value | Description |
| --- | --- |
| `"idle"` | there is no `src`. |
| `"loading"` | the image loads, or waits for `Cropper.Viewport` to have a size. |
| `"loaded"` | the image is on screen with its crop. |
| `"error"` | the image failed to load. |

### CropperLoadedImage

The image the cropper shows, once loaded.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| src (required) | `string` | - | - |
| width (required) | `number` | - | Natural size. |
| height (required) | `number` | - | - |
| element (required) | `HTMLImageElement` | - | - |

### CropperRootActions

What the cropper can do from code: through `actionsRef`, or through
`useCropperRootContext()` in a part of your own.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| getState (required) | `() => CropperState \| null` | - | The full state, or `null` before the image is on screen. |
| getCoordinates (required) | `() => CropperCoordinates \| null` | - | The crop in whole pixels of the (rotated) image. |
| getCanvas (required) | `(options?: CropperCanvasOptions) => HTMLCanvasElement \| null` | - | Draws the crop on a new canvas. The image must allow it (`crossOrigin`). |
| setCoordinates (required) | `( coordinates: Exclude<CropperCoordinatesTransform, null>, ) => void` | - | Moves or resizes the crop, in image pixels; a size without position is kept centered. |
| setState (required) | `(state: CropperState) => void` | - | Replaces the state, such as one saved from `getState()`. |
| zoom (required) | `(factor: number, center?: CropperPoint) => void` | - | Zooms by `factor` (above 1 zooms in), around a point of the image or the center. |
| setZoom (required) | `(zoom: number) => void` | - | Zooms to `zoom`, from 0 (widest view) to 1 (closest view). |
| move (required) | `(left: number, top: number) => void` | - | Moves the view by image pixels. |
| rotate (required) | `(angle: number) => void` | - | Rotates the image by `angle` degrees, clockwise. |
| flip (required) | `(horizontal: boolean, vertical?: boolean) => void` | - | Mirrors the image as seen on screen. |
| reset (required) | `() => void` | - | Starts over with the default crop. |

### CropperAspectRatio

The aspect ratio (width / height) the crop must keep: one number, or a
range. Leave out a bound to leave that side free.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| toString (required) | `undefined` | - | - |
| valueOf (required) | `undefined` | - | - |
| toLocaleString (required) | `undefined` | - | - |

### CropperCanvasOptions

Options of the canvas `getCanvas()` draws the crop on.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| width | `number` | - | Width of the result; the height follows the crop's aspect ratio. |
| height | `number` | - | Height of the result; the width follows the crop's aspect ratio. |
| minWidth | `number` | - | - |
| minHeight | `number` | - | - |
| maxWidth | `number` | - | - |
| maxHeight | `number` | - | - |
| maxArea | `number` | - | Largest number of pixels (width × height) of the result. |
| fillColor | `string` | `"transparent"` | Colour drawn behind the image, seen where the crop leaves it. |
| imageSmoothingEnabled | `boolean` | `true` | - |
| imageSmoothingQuality | `"high" \| "low" \| "medium"` | `"high"` | - |

### CropperCoordinates

A rectangle: its top left corner and its size.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| left (required) | `number` | - | - |
| top (required) | `number` | - | - |
| width (required) | `number` | - | - |
| height (required) | `number` | - | - |

### CropperDefaultTransforms

Transforms to start from; a missing field keeps the image as it is.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| rotate | `number` | - | - |
| flip | `{ horizontal?: boolean \| undefined; vertical?: boolean \| undefined; }` | - | - |

### CropperPoint

A point, in image pixels unless stated otherwise.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| left (required) | `number` | - | - |
| top (required) | `number` | - | - |

### CropperPositionRestrictions

Edges a rectangle must stay within, in image pixels. A missing edge is free.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| left | `number` | - | - |
| top | `number` | - | - |
| right | `number` | - | - |
| bottom | `number` | - | - |

### CropperSize

A size, in image pixels unless stated otherwise.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| width (required) | `number` | - | - |
| height (required) | `number` | - | - |

### CropperSizeRestrictions

Smallest and largest size, in image pixels.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| minWidth (required) | `number` | - | - |
| minHeight (required) | `number` | - | - |
| maxWidth (required) | `number` | - | - |
| maxHeight (required) | `number` | - | - |

### CropperTransforms

Rotation in degrees and mirroring applied to the image.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| rotate (required) | `number` | - | - |
| flip (required) | `{ horizontal: boolean; vertical: boolean; }` | - | - |
