Media

Image cropper

Crops an image with a movable, resizable frame, free or fixed ratio, with zoom, rotation and canvas export.

Built on
Pointer captureResizeObserver<canvas><input type="range">
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
5.6 kB
Built from
ButtonIcon
Hills under a yellow sun

Arrow keys move the crop. Shift and arrow keys resize it.

Code
"use client";

import { ImageCropper } from "@/components/equip/image-cropper";

/* A landscape drawn as an SVG data URL, so the demo fetches nothing. */
const photo = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 120 80"><rect width="120" height="80" fill="#9cc6d9"/><circle cx="84" cy="24" r="10" fill="#f6c453"/><path d="M0 58 Q30 36 60 54 T120 48 V80 H0Z" fill="#6f9a63"/><path d="M0 70 Q40 58 80 70 T120 64 V80 H0Z" fill="#3e6b47"/></svg>',
)}`;

export default function Demo() {
  return <ImageCropper src={photo} alt="Hills under a yellow sun" aspect={1} maxHeight={84} style={{ width: 216 }} />;
}

Examples

Profile picture with an export

cropImage draws the crop on a canvas at full resolution and returns a Blob, ready to upload; nothing leaves the browser here.

Portrait of a person in a blue jumper

Arrow keys move the crop. Shift and arrow keys resize it.

Code
"use client";

import { useRef, useState } from "react";
import { Button } from "@/components/equip/button";
import { cropImage, ImageCropper, type CropArea } from "@/components/equip/image-cropper";

const portrait = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1200" viewBox="0 0 90 120"><rect width="90" height="120" fill="#e9d8c4"/><circle cx="45" cy="48" r="20" fill="#b07a55"/><path d="M15 120 Q15 78 45 76 Q75 78 75 120Z" fill="#4c6a8a"/><path d="M25 44 Q26 22 45 22 Q66 22 65 44 Q60 32 45 32 Q30 32 25 44Z" fill="#3b2a20"/></svg>',
)}`;

export default function Example() {
  const area = useRef<CropArea | null>(null);
  const [preview, setPreview] = useState<string>();
  const [size, setSize] = useState("");

  async function save() {
    if (!area.current) return;
    const blob = await cropImage(portrait, area.current);
    if (!blob) return;
    if (preview) URL.revokeObjectURL(preview);
    setPreview(URL.createObjectURL(blob));
    setSize(`${area.current.width} × ${area.current.height} px, ${Math.round(blob.size / 1000)} kB`);
  }

  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 16, alignItems: "flex-start" }}>
      <ImageCropper
        src={portrait}
        alt="Portrait of a person in a blue jumper"
        aspect={1}
        shape="round"
        maxHeight={260}
        onCropChange={(next) => (area.current = next)}
        style={{ flex: "1 1 220px", width: "auto" }}
      />
      <div style={{ display: "grid", gap: 8, justifyItems: "start" }}>
        <Button variant="accent" onClick={save}>
          Save crop
        </Button>
        {preview && (
          <>
            <img src={preview} alt="Cropped result" width={72} height={72} style={{ borderRadius: "50%" }} />
            <span style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{size}</span>
          </>
        )}
      </div>
    </div>
  );
}

Free or fixed ratio

Changing aspect refits the frame. The crop is reported in pixels of the source image, after any rotation.

A sailing boat by a lighthouse

Arrow keys move the crop. Shift and arrow keys resize it.

Loading image
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { ImageCropper, type CropArea } from "@/components/equip/image-cropper";

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

const harbour = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 160 100"><rect width="160" height="100" fill="#f2c9a0"/><rect y="60" width="160" height="40" fill="#5f8fb0"/><path d="M30 60 h30 l-6 8 h-20z" fill="#7a3d2c"/><path d="M44 60 v-26 l14 22z" fill="#fbf4e8"/><path d="M110 60 v-30 h8 v30z" fill="#e8e1d4"/><path d="M106 30 h16 l-8 -8z" fill="#b23a3a"/></svg>',
)}`;

export default function Example() {
  const [aspect, setAspect] = useState<number | undefined>(16 / 9);
  const [area, setArea] = useState<CropArea | null>(null);
  return (
    <div style={{ display: "grid", gap: 12, width: "100%" }}>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }} role="group" aria-label="Aspect ratio">
        {ratios.map((r) => (
          <Button key={r.label} size="s" variant="outline" pressed={aspect === r.value} onClick={() => setAspect(r.value)}>
            {r.label}
          </Button>
        ))}
      </div>
      <ImageCropper src={harbour} alt="A sailing boat by a lighthouse" aspect={aspect} onCropChange={setArea} maxHeight={240} />
      <output style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)", fontVariantNumeric: "tabular-nums", textAlign: "center" }}>
        {area ? `x ${area.x}, y ${area.y}, ${area.width} × ${area.height} px, turned ${area.rotation}°` : "Loading image"}
      </output>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
srcstringnoneThe image. Same origin, a data or blob URL, or served with CORS if you export it.
altstringnoneDescribes the image.
aspectnumbernoneWidth divided by height, such as 1 or 16 / 9. Leave it out for a free frame. Changing it refits the frame.
shape"rect" | "round""rect"Round draws a circular mask for profile pictures; the crop is still a rectangle.
maxZoomnumber3Largest zoom, as a multiple of the fitted size.
defaultRotation0 | 90 | 180 | 2700Rotation on first render.
maxHeightnumber320Tallest the image area may get, in pixels. Its width follows the box.
onCropChange(area: CropArea) => voidnoneCalled with { x, y, width, height, rotation } in pixels of the rotated source whenever the crop changes.
crossOrigin"anonymous" | "use-credentials"nonePassed to the img, for exporting images from another origin.
...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-image-cropperdata-shape is rect or round; data-ready once the image has loaded and been measured.
stage.eq-image-cropper-stageThe window onto the image, sized to fit it; reads drags.
canvas.eq-image-cropper-canvasThe zoomed image and frame, shifted to keep the frame in view.
image.eq-image-cropper-image
frame.eq-image-cropper-frameThe crop. Its spread shadow dims the rest of the image.
grid.eq-image-cropper-gridRule-of-thirds lines while held or focused.
handle.eq-image-cropper-handleCorner handles; data-corner is nw, ne, sw or se.
toolbar.eq-image-cropper-toolbar
zoom.eq-image-cropper-zoomA label around the zoom slider and its icons.
range.eq-image-cropper-rangeThe native zoom slider.
sr.eq-image-cropper-srVisually hidden help and the live crop readout.

Keyboard

ArrowLeftArrowRightArrowUpArrowDownWith the frame focused, moves it by 1% of the image.
Shift+ArrowRightShift+ArrowDownMakes the frame larger by 1%, keeping the ratio when one is set.
Shift+ArrowLeftShift+ArrowUpMakes the frame smaller by 1%.
ArrowLeftArrowRightOn the zoom slider, zooms out or in; Home and End go to the limits.
EnterSpaceOn the rotate button, turns the image 90 degrees clockwise.

Notes

  • The frame is a focusable group described by a short help text and a polite readout such as "Crop 640 by 640 pixels at 120, 80".
  • Dragging the frame moves it, dragging a corner resizes it from the opposite corner, and every drag has the keyboard equivalent listed.
  • When zoomed, the view follows the frame after each move so it stays in sight.
  • The part never uploads; read the area from onCropChange and pass it to cropImage or cropToCanvas, both exported from the same file.
  • cropImage returns a Blob at the source's full resolution; an image from another origin needs CORS and crossOrigin, or the canvas is tainted and export fails.
  • The demo and examples use SVG data URLs drawn for them, so they fetch nothing.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/image-cropper.tsx324 lines
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from "react";
import { Button } from "./button";
import { Icon } from "./icon";
import "./image-cropper.css";

/** The crop in pixels of the source image after rotation, and that rotation in degrees. */
export type CropArea = { x: number; y: number; width: number; height: number; rotation: 0 | 90 | 180 | 270 };

export type ImageCropperProps = Omit<ComponentProps<"div">, "children" | "onChange"> & {
  /** The image to crop. Same origin, a data or blob URL, or CORS-enabled if you export it. */
  src: string;
  /** Describes the image. */
  alt: string;
  /** Width divided by height, such as 1 or 16 / 9. Leave it out for a free frame. */
  aspect?: number;
  /** Shows a round mask, for profile pictures. The crop itself stays a rectangle. */
  shape?: "rect" | "round";
  /** Largest zoom, as a multiple of the fitted size. */
  maxZoom?: number;
  /** Rotation on first render. */
  defaultRotation?: CropArea["rotation"];
  /** Tallest the image area may get, in pixels. */
  maxHeight?: number;
  /** Called with the crop in source pixels whenever it changes, once the image has loaded. */
  onCropChange?: (area: CropArea) => void;
  /** Passed to the img, for exporting images from another origin. */
  crossOrigin?: ComponentProps<"img">["crossOrigin"];
};

type Box = { x: number; y: number; w: number; h: number };
type Corner = "nw" | "ne" | "sw" | "se";
type Drag = { mode: "move" | Corner; px: number; py: number; start: Box };

const MIN = 0.05;
const STEP = 0.01;
const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);

/** The largest frame of the aspect that fits, centred; k is the frame's height per unit of width, in fractions. */
function fitted(k: number | null): Box {
  if (k === null) return { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
  const w = Math.min(0.9, 0.9 / k);
  const h = w * k;
  return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}

/** Draws the crop onto a canvas at full resolution. */
export function cropToCanvas(image: HTMLImageElement, area: CropArea): HTMLCanvasElement {
  const canvas = document.createElement("canvas");
  canvas.width = Math.max(1, Math.round(area.width));
  canvas.height = Math.max(1, Math.round(area.height));
  const g = canvas.getContext("2d");
  if (!g) return canvas;
  const w = image.naturalWidth;
  const h = image.naturalHeight;
  const turned = area.rotation % 180 !== 0;
  g.translate(-area.x, -area.y);
  g.translate((turned ? h : w) / 2, (turned ? w : h) / 2);
  g.rotate((area.rotation * Math.PI) / 180);
  g.drawImage(image, -w / 2, -h / 2);
  return canvas;
}

/** Loads the image if given a URL, crops it and encodes it, by default as PNG. */
export async function cropImage(source: string | HTMLImageElement, area: CropArea, type = "image/png", quality?: number): Promise<Blob | null> {
  let image: HTMLImageElement;
  if (typeof source === "string") {
    image = new Image();
    image.crossOrigin = "anonymous";
    image.src = source;
    await image.decode();
  } else image = source;
  const canvas = cropToCanvas(image, area);
  return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}

export function ImageCropper({
  src,
  alt,
  aspect,
  shape = "rect",
  maxZoom = 3,
  defaultRotation = 0,
  maxHeight = 320,
  onCropChange,
  crossOrigin,
  className,
  style,
  ...rest
}: ImageCropperProps) {
  const id = useId();
  const root = useRef<HTMLDivElement>(null);
  const image = useRef<HTMLImageElement>(null);
  const drag = useRef<Drag | null>(null);
  const report = useRef(onCropChange);
  report.current = onCropChange;
  const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
  const [room, setRoom] = useState(0);
  const [rotation, setRotation] = useState<CropArea["rotation"]>(defaultRotation);
  const [zoom, setZoom] = useState(1);
  const [crop, setCrop] = useState<Box>(() => fitted(null));
  const [view, setView] = useState({ x: 0.5, y: 0.5 });
  const [dragging, setDragging] = useState(false);

  const turned = rotation % 180 !== 0;
  const rw = natural ? (turned ? natural.h : natural.w) : 4;
  const rh = natural ? (turned ? natural.w : natural.h) : 3;
  // Frame height per unit of frame width, in fractions of the rotated image.
  const k = aspect ? rw / (aspect * rh) : null;

  // Width available, measured after mount and on resize.
  useEffect(() => {
    const node = root.current;
    if (!node) return;
    setRoom(node.clientWidth);
    const observer = new ResizeObserver(([entry]) => entry && setRoom(entry.contentRect.width));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  // An image that finished loading before hydration never fires load.
  useEffect(() => {
    const img = image.current;
    if (img?.complete && img.naturalWidth > 0) setNatural({ w: img.naturalWidth, h: img.naturalHeight });
  }, [src]);

  // A new image, aspect or rotation starts from the largest frame that fits.
  useEffect(() => {
    setCrop(fitted(k));
    setView({ x: 0.5, y: 0.5 });
  }, [k, src]);

  useEffect(() => {
    if (!natural) return;
    report.current?.({
      x: Math.round(crop.x * rw),
      y: Math.round(crop.y * rh),
      width: Math.round(crop.w * rw),
      height: Math.round(crop.h * rh),
      rotation,
    });
  }, [crop, natural, rotation, rw, rh]);

  const ratio = rw / rh;
  const fitW = room > 0 ? Math.min(room, maxHeight * ratio) : 0;
  const fitH = fitW / ratio;
  const cw = fitW * zoom;
  const ch = fitH * zoom;
  const ox = clamp(fitW / 2 - view.x * cw, fitW - cw, 0);
  const oy = clamp(fitH / 2 - view.y * ch, fitH - ch, 0);

  function centre(box: Box) {
    setView({ x: box.x + box.w / 2, y: box.y + box.h / 2 });
  }

  function resize(start: Box, corner: Corner, dx: number, dy: number): Box {
    const east = corner === "ne" || corner === "se";
    const south = corner === "sw" || corner === "se";
    // The corner across from the one held stays put.
    const ax = east ? start.x : start.x + start.w;
    const ay = south ? start.y : start.y + start.h;
    const roomX = east ? 1 - ax : ax;
    const roomY = south ? 1 - ay : ay;
    let w = clamp(start.w + (east ? dx : -dx), MIN, roomX);
    let h = clamp(start.h + (south ? dy : -dy), MIN, roomY);
    if (k !== null) {
      w = clamp(Math.max(w, h / k), Math.max(MIN, MIN / k), Math.min(roomX, roomY / k));
      h = w * k;
    }
    return { x: east ? ax : ax - w, y: south ? ay : ay - h, w, h };
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    if (event.button !== 0 || !(event.target instanceof Element) || cw === 0) return;
    const handle = event.target.closest<HTMLElement>("[data-corner]");
    const onFrame = event.target.closest(".eq-image-cropper-frame");
    if (!onFrame) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    if (onFrame instanceof HTMLElement) onFrame.focus({ preventScroll: true });
    const corner = handle?.dataset.corner as Corner | undefined;
    drag.current = { mode: corner ?? "move", px: event.clientX, py: event.clientY, start: crop };
    setDragging(true);
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d || cw === 0) return;
    const dx = (event.clientX - d.px) / cw;
    const dy = (event.clientY - d.py) / ch;
    if (d.mode === "move") {
      setCrop({ ...d.start, x: clamp(d.start.x + dx, 0, 1 - d.start.w), y: clamp(d.start.y + dy, 0, 1 - d.start.h) });
    } else setCrop(resize(d.start, d.mode, dx, dy));
  }

  function endDrag() {
    if (!drag.current) return;
    drag.current = null;
    setDragging(false);
    centre(crop);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const dx = event.key === "ArrowRight" ? STEP : event.key === "ArrowLeft" ? -STEP : 0;
    const dy = event.key === "ArrowDown" ? STEP : event.key === "ArrowUp" ? -STEP : 0;
    if (dx === 0 && dy === 0) return;
    event.preventDefault();
    event.stopPropagation();
    const next = event.shiftKey
      ? resize(crop, "se", dx, k !== null && dx !== 0 ? dx * k : dy)
      : { ...crop, x: clamp(crop.x + dx, 0, 1 - crop.w), y: clamp(crop.y + dy, 0, 1 - crop.h) };
    setCrop(next);
    centre(next);
  }

  // With a fixed aspect the frame is refitted by the effect above, since k changes with the turn.
  function rotate() {
    setRotation((r) => ((r + 90) % 360) as CropArea["rotation"]);
    setView({ x: 0.5, y: 0.5 });
  }

  const pixels = natural ? `${Math.round(crop.w * rw)} by ${Math.round(crop.h * rh)} pixels` : "";
  const at = natural ? `at ${Math.round(crop.x * rw)}, ${Math.round(crop.y * rh)}` : "";

  return (
    <div
      ref={root}
      {...rest}
      className={["eq-image-cropper", className].filter(Boolean).join(" ")}
      style={style}
      data-shape={shape}
      data-ready={natural && fitW > 0 ? "" : undefined}
    >
      <div
        className="eq-image-cropper-stage"
        style={{ width: fitW || undefined, height: fitH || undefined }}
        data-dragging={dragging || undefined}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onLostPointerCapture={endDrag}
      >
        <div
          className="eq-image-cropper-canvas"
          style={{ width: cw, height: ch, translate: `${ox}px ${oy}px` } as CSSProperties}
        >
          <img
            ref={image}
            className="eq-image-cropper-image"
            src={src}
            alt={alt}
            crossOrigin={crossOrigin}
            draggable={false}
            onLoad={(event) => setNatural({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
            style={{
              width: turned ? ch : cw,
              height: turned ? cw : ch,
              rotate: `${rotation}deg`,
            }}
          />
          <div
            className="eq-image-cropper-frame"
            role="group"
            tabIndex={0}
            aria-label="Crop area"
            aria-describedby={`${id}-help ${id}-status`}
            style={{
              left: `${crop.x * 100}%`,
              top: `${crop.y * 100}%`,
              width: `${crop.w * 100}%`,
              height: `${crop.h * 100}%`,
            }}
            onKeyDown={handleKeyDown}
          >
            <span className="eq-image-cropper-grid" aria-hidden="true" />
            {(["nw", "ne", "sw", "se"] as const).map((corner) => (
              <span key={corner} className="eq-image-cropper-handle" data-corner={corner} aria-hidden="true" />
            ))}
          </div>
        </div>
      </div>
      <div className="eq-image-cropper-toolbar">
        <label className="eq-image-cropper-zoom">
          <Icon name="zoom-out" size={16} />
          <span className="eq-image-cropper-sr">Zoom</span>
          <input
            className="eq-image-cropper-range"
            type="range"
            min={1}
            max={maxZoom}
            step={0.05}
            value={zoom}
            aria-valuetext={`${Math.round(zoom * 100)}%`}
            style={{ "--_fill": (zoom - 1) / Math.max(0.01, maxZoom - 1) } as CSSProperties}
            onChange={(event) => {
              setZoom(event.currentTarget.valueAsNumber);
              centre(crop);
            }}
          />
          <Icon name="zoom-in" size={16} />
        </label>
        <Button variant="outline" size="s" aria-label="Rotate 90 degrees" lead={<Icon name="refresh" size={16} />} onClick={rotate} />
      </div>
      <p id={`${id}-help`} className="eq-image-cropper-sr">
        Arrow keys move the crop. Shift and arrow keys resize it.
      </p>
      <p id={`${id}-status`} className="eq-image-cropper-sr" aria-live="polite">
        {pixels && !dragging && `Crop ${pixels} ${at}.`}
      </p>
    </div>
  );
}
components/equip/image-cropper.css258 lines
@layer equip {
  .eq-image-cropper {
    display: grid;
    gap: var(--eq-space-2);
    justify-items: center;
    /* It measures its own width to fit the image, so it fills its box unless given one. */
    width: 100%;
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.35 var(--eq-font);
  }

  .eq-image-cropper-stage {
    position: relative;
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
    border-radius: var(--eq-radius-m);
    background: var(--eq-tint-strong);
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
  }

  .eq-image-cropper:not([data-ready]) .eq-image-cropper-stage {
    width: 100%;
    aspect-ratio: 4 / 3;
  }

  .eq-image-cropper-canvas {
    position: absolute;
    top: 0;
    left: 0;
  }

  .eq-image-cropper:not([data-ready]) .eq-image-cropper-canvas {
    visibility: hidden;
  }

  .eq-image-cropper-image {
    position: absolute;
    top: 50%;
    left: 50%;
    display: block;
    max-width: none;
    translate: -50% -50%;
    pointer-events: none;
  }

  /* The frame. Its spread shadow dims everything outside it; the stage clips the rest. */
  .eq-image-cropper-frame {
    position: absolute;
    box-sizing: border-box;
    border: 1px solid var(--eq-raised);
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--eq-ink) 40%, transparent),
      0 0 0 100vmax var(--eq-backdrop);
    cursor: move;
    touch-action: none;
  }

  .eq-image-cropper[data-shape="round"] .eq-image-cropper-frame {
    border-radius: var(--eq-radius-full);
  }

  .eq-image-cropper-frame:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-image-cropper[data-shape="round"] .eq-image-cropper-frame:focus-visible {
    outline-offset: 4px;
  }

  /* Rule of thirds, shown while the frame is held or focused. */
  .eq-image-cropper-grid {
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background:
      linear-gradient(to right, transparent calc(100% / 3 - 0.5px), var(--_line) 0 calc(100% / 3 + 0.5px), transparent 0 calc(200% / 3 - 0.5px), var(--_line) 0 calc(200% / 3 + 0.5px), transparent 0),
      linear-gradient(to bottom, transparent calc(100% / 3 - 0.5px), var(--_line) 0 calc(100% / 3 + 0.5px), transparent 0 calc(200% / 3 - 0.5px), var(--_line) 0 calc(200% / 3 + 0.5px), transparent 0);
    opacity: 0;
    transition: opacity var(--eq-dur) var(--eq-ease);
    --_line: color-mix(in srgb, var(--eq-raised) 70%, transparent);
  }

  .eq-image-cropper-stage[data-dragging] .eq-image-cropper-grid,
  .eq-image-cropper-frame:focus-visible .eq-image-cropper-grid {
    opacity: 1;
  }

  .eq-image-cropper-handle {
    position: absolute;
    width: 12px;
    height: 12px;
    box-sizing: border-box;
    border: 2px solid var(--eq-solid);
    border-radius: var(--eq-radius-s);
    background: var(--eq-raised);
    transition: scale var(--eq-dur-move) var(--eq-spring);
  }

  /* A 28px target round each 12px handle. */
  .eq-image-cropper-handle::before {
    content: "";
    position: absolute;
    inset: -8px;
  }

  .eq-image-cropper-handle[data-corner="nw"] {
    top: -6px;
    left: -6px;
    cursor: nwse-resize;
  }

  .eq-image-cropper-handle[data-corner="ne"] {
    top: -6px;
    right: -6px;
    cursor: nesw-resize;
  }

  .eq-image-cropper-handle[data-corner="sw"] {
    bottom: -6px;
    left: -6px;
    cursor: nesw-resize;
  }

  .eq-image-cropper-handle[data-corner="se"] {
    right: -6px;
    bottom: -6px;
    cursor: nwse-resize;
  }

  .eq-image-cropper[data-shape="round"] .eq-image-cropper-handle[data-corner="nw"] {
    top: calc(14.6% - 6px);
    left: calc(14.6% - 6px);
  }

  .eq-image-cropper[data-shape="round"] .eq-image-cropper-handle[data-corner="ne"] {
    top: calc(14.6% - 6px);
    right: calc(14.6% - 6px);
  }

  .eq-image-cropper[data-shape="round"] .eq-image-cropper-handle[data-corner="sw"] {
    bottom: calc(14.6% - 6px);
    left: calc(14.6% - 6px);
  }

  .eq-image-cropper[data-shape="round"] .eq-image-cropper-handle[data-corner="se"] {
    right: calc(14.6% - 6px);
    bottom: calc(14.6% - 6px);
  }

  .eq-image-cropper-handle:active {
    scale: 1.2;
  }

  .eq-image-cropper-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: var(--eq-space-2) var(--eq-space-3);
    max-width: 100%;
  }

  .eq-image-cropper-zoom {
    display: flex;
    min-width: 0;
    align-items: center;
    gap: var(--eq-space-2);
    color: var(--eq-ink-2);
  }

  .eq-image-cropper-range {
    --_thumb: 14px;
    --_track: 4px;
    --_stop: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_fill, 0));

    appearance: none;
    display: block;
    width: 120px;
    min-width: 48px;
    flex: 0 1 auto;
    height: 24px;
    margin: 0;
    background: transparent;
    cursor: pointer;
  }

  .eq-image-cropper-range:focus-visible {
    outline: none;
  }

  .eq-image-cropper-range::-webkit-slider-runnable-track {
    height: var(--_track);
    border-radius: var(--eq-radius-full);
    background: linear-gradient(to right, var(--eq-solid) var(--_stop), transparent var(--_stop)), var(--eq-tint-strong);
  }

  .eq-image-cropper-range::-webkit-slider-thumb {
    appearance: none;
    box-sizing: border-box;
    width: var(--_thumb);
    height: var(--_thumb);
    margin-top: calc((var(--_track) - var(--_thumb)) / 2);
    border: 2px solid var(--eq-solid);
    border-radius: var(--eq-radius-full);
    background: var(--eq-raised);
    transition: scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-image-cropper-range:focus-visible::-webkit-slider-thumb {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-image-cropper-range:active::-webkit-slider-thumb {
    scale: 1.15;
  }

  .eq-image-cropper-range::-moz-range-track {
    height: var(--_track);
    border-radius: var(--eq-radius-full);
    background: var(--eq-tint-strong);
  }

  .eq-image-cropper-range::-moz-range-progress {
    height: var(--_track);
    border-radius: var(--eq-radius-full);
    background: var(--eq-solid);
  }

  .eq-image-cropper-range::-moz-range-thumb {
    box-sizing: border-box;
    width: var(--_thumb);
    height: var(--_thumb);
    border: 2px solid var(--eq-solid);
    border-radius: var(--eq-radius-full);
    background: var(--eq-raised);
  }

  .eq-image-cropper-range:focus-visible::-moz-range-thumb {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-image-cropper-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index