Skip to main content

ns-ui

Image Crop Mat

Image cropping as a passe-partout: four mat boards slide over a photo to define the crop window, snap to ratio presets with a settle, and show a rule-of-thirds grid only while dragging.

Usage

registry/core/image-crop-mat/demo.tsx
"use client";

import { useEffect, useRef } from "react";
import { MatCrop } from "./component";

// Self-driving: drags the right mat edge in and out via real PointerEvents
// (catching the grid-while-dragging state), then cycles through the ratio
// presets so the settle transition is visible — no pointer/keyboard input
// from a viewer is ever required.
function firePointer(el: Element, type: string, x: number, y: number) {
  el.dispatchEvent(
    new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, clientX: x, clientY: y, pointerType: "mouse" })
  );
}

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function dragHandle(handle: Element, stage: Element, dxTotal: number, steps = 16) {
  const rect = handle.getBoundingClientRect();
  const startX = rect.left + rect.width / 2;
  const startY = rect.top + rect.height / 2;
  firePointer(handle, "pointerdown", startX, startY);
  for (let i = 1; i <= steps; i++) {
    await wait(16);
    firePointer(stage, "pointermove", startX + (dxTotal * i) / steps, startY);
  }
  await wait(10);
  firePointer(stage, "pointerup", startX + dxTotal, startY);
}

export default function MatCropDemo() {
  const containerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    let cancelled = false;
    const isCancelled = () => cancelled;

    async function loop() {
      const root = containerRef.current;
      if (!root) return;
      const stage = root.querySelector(".ns-mc-stage");
      if (!stage) return;

      await wait(500);
      while (!isCancelled()) {
        const rightGrip = root.querySelector('[aria-label="Right edge"]');
        if (rightGrip && !isCancelled()) await dragHandle(rightGrip, stage, -50);
        await wait(1400);
        if (isCancelled()) break;

        const btn169 = Array.from(root.querySelectorAll("button")).find((b) => b.textContent === "16:9");
        btn169?.click();
        await wait(2600);
        if (isCancelled()) break;

        const btn11 = Array.from(root.querySelectorAll("button")).find((b) => b.textContent === "1:1");
        btn11?.click();
        await wait(2600);
        if (isCancelled()) break;

        const topGrip = root.querySelector('[aria-label="Top edge"]');
        if (topGrip && !isCancelled()) await dragHandle(topGrip, stage, 0);
        await wait(1200);

        const btnFree = Array.from(root.querySelectorAll("button")).find((b) => b.textContent === "Free");
        btnFree?.click();
        await wait(2400);
      }
    }

    void loop();
    return () => {
      cancelled = true;
    };
  }, []);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / image-crop-mat</p>

      <div ref={containerRef}>
        <MatCrop />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Drag a mat edge or corner to define the crop; a rule-of-thirds grid
        appears only while dragging. Ratio presets snap the mats to a settle.
      </p>
    </div>
  );
}

Component

registry/core/image-crop-mat/component.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";

// ---------------------------------------------------------------------------
// MatCrop — image cropping as a passe-partout: four mat boards slide over a
// photo to define the crop window, rather than a bounding-box overlay with
// dashed handles. Dragging any edge or corner moves that mat directly
// (1:1 with the pointer — a deliberate simplification of "paper friction",
// reserved below for the one place it's most visible); ratio presets snap
// the mats to the nearest valid rect at that ratio with a short, deliberately
// no-overshoot ease (standing in for "critically damped, no bounce" without
// a literal spring simulation). The exposed window shows a rule-of-thirds
// grid only while actively dragging.
// ---------------------------------------------------------------------------

export interface CropRect {
  x: number;
  y: number;
  width: number;
  height: number;
  ratio: string;
}

export interface MatCropProps {
  /** called with the new crop rect on every drag/resize */
  onChange?: (rect: CropRect) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const DISPLAY_W = 480;
const DISPLAY_H = 300;
const NATURAL_SCALE = 4; // 1 display px = 4 "real photo" px, for a realistic readout
const MIN_SIZE = 60;

type Rect = { top: number; left: number; width: number; height: number };
type EdgeHandle = "top" | "right" | "bottom" | "left";
type CornerHandle = "tl" | "tr" | "bl" | "br";

const RATIO_PRESETS: { label: string; ratio: number | null }[] = [
  { label: "1:1", ratio: 1 },
  { label: "4:5", ratio: 4 / 5 },
  { label: "16:9", ratio: 16 / 9 },
  { label: "Free", ratio: null },
];

function clamp(n: number, min: number, max: number) {
  return Math.min(max, Math.max(min, n));
}

function ratioLabelFor(width: number, height: number): string {
  const r = width / height;
  for (const preset of RATIO_PRESETS) {
    if (preset.ratio && Math.abs(r - preset.ratio) < 0.02) return preset.label;
  }
  return "Free";
}

function fitRatio(current: Rect, ratio: number): Rect {
  const cx = current.left + current.width / 2;
  const cy = current.top + current.height / 2;
  let w = DISPLAY_W * 0.82;
  let h = w / ratio;
  if (h > DISPLAY_H * 0.82) {
    h = DISPLAY_H * 0.82;
    w = h * ratio;
  }
  const left = clamp(cx - w / 2, 0, DISPLAY_W - w);
  const top = clamp(cy - h / 2, 0, DISPLAY_H - h);
  return { top, left, width: w, height: h };
}

export function MatCrop({ onChange, className = "" }: MatCropProps) {
  const autoId = useId().replace(/:/g, "");
  const stageRef = useRef<HTMLDivElement | null>(null);
  const topMatRef = useRef<HTMLDivElement | null>(null);
  const rightMatRef = useRef<HTMLDivElement | null>(null);
  const bottomMatRef = useRef<HTMLDivElement | null>(null);
  const leftMatRef = useRef<HTMLDivElement | null>(null);
  const windowRef = useRef<HTMLDivElement | null>(null);
  const rectRef = useRef<Rect>({ top: 40, left: 60, width: 360, height: 220 });
  const dragRef = useRef<{ handle: EdgeHandle | CornerHandle; startX: number; startY: number; start: Rect } | null>(
    null
  );
  const reducedRef = useRef(false);

  const [rect, setRect] = useState<Rect>(rectRef.current);
  const [dragging, setDragging] = useState(false);
  const [activePreset, setActivePreset] = useState("Free");
  const [announce, setAnnounce] = useState("");

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onChangeMq = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChangeMq);
    return () => mq.removeEventListener("change", onChangeMq);
  }, []);

  const paint = useCallback((r: Rect, animate: boolean) => {
    const ms = animate && !reducedRef.current ? 180 : 0;
    const ease = "cubic-bezier(0.2, 0.75, 0.25, 1)"; // no-overshoot ease-out
    const apply = (el: HTMLElement | null, styles: Partial<CSSStyleDeclaration>) => {
      if (!el) return;
      el.style.transition = ms > 0 ? `top ${ms}ms ${ease}, left ${ms}ms ${ease}, right ${ms}ms ${ease}, bottom ${ms}ms ${ease}, width ${ms}ms ${ease}, height ${ms}ms ${ease}` : "none";
      Object.assign(el.style, styles);
    };
    apply(topMatRef.current, { height: `${r.top}px` });
    apply(bottomMatRef.current, { top: `${r.top + r.height}px`, height: `${DISPLAY_H - r.top - r.height}px` });
    apply(leftMatRef.current, { top: `${r.top}px`, height: `${r.height}px`, width: `${r.left}px` });
    apply(rightMatRef.current, {
      top: `${r.top}px`,
      height: `${r.height}px`,
      left: `${r.left + r.width}px`,
      width: `${DISPLAY_W - r.left - r.width}px`,
    });
    apply(windowRef.current, { top: `${r.top}px`, left: `${r.left}px`, width: `${r.width}px`, height: `${r.height}px` });
  }, []);

  const commit = useCallback(
    (r: Rect, animate: boolean) => {
      rectRef.current = r;
      setRect(r);
      paint(r, animate);
      const label = ratioLabelFor(r.width, r.height);
      setActivePreset(label);
      onChange?.({
        x: Math.round(r.left * NATURAL_SCALE),
        y: Math.round(r.top * NATURAL_SCALE),
        width: Math.round(r.width * NATURAL_SCALE),
        height: Math.round(r.height * NATURAL_SCALE),
        ratio: label,
      });
    },
    [onChange, paint]
  );

  useEffect(() => {
    paint(rectRef.current, false);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const applyPreset = useCallback(
    (label: string, ratio: number | null) => {
      const next = ratio ? fitRatio(rectRef.current, ratio) : rectRef.current;
      commit(next, true);
      setAnnounce(`Crop set to ${label}.`);
    },
    [commit]
  );

  const onEdgePointerDown = useCallback((handle: EdgeHandle | CornerHandle) => (e: ReactPointerEvent) => {
    (e.target as Element).setPointerCapture(e.pointerId);
    dragRef.current = { handle, startX: e.clientX, startY: e.clientY, start: { ...rectRef.current } };
    setDragging(true);
  }, []);

  const onEdgePointerMove = useCallback(
    (e: ReactPointerEvent) => {
      const drag = dragRef.current;
      if (!drag) return;
      const dx = e.clientX - drag.startX;
      const dy = e.clientY - drag.startY;
      const s = drag.start;
      let { top, left, width, height } = s;

      const adjustTop = () => {
        const newTop = clamp(s.top + dy, 0, s.top + s.height - MIN_SIZE);
        height = s.height - (newTop - s.top);
        top = newTop;
      };
      const adjustBottom = () => {
        height = clamp(s.height + dy, MIN_SIZE, DISPLAY_H - s.top);
      };
      const adjustLeft = () => {
        const newLeft = clamp(s.left + dx, 0, s.left + s.width - MIN_SIZE);
        width = s.width - (newLeft - s.left);
        left = newLeft;
      };
      const adjustRight = () => {
        width = clamp(s.width + dx, MIN_SIZE, DISPLAY_W - s.left);
      };

      if (drag.handle === "top" || drag.handle === "tl" || drag.handle === "tr") adjustTop();
      if (drag.handle === "bottom" || drag.handle === "bl" || drag.handle === "br") adjustBottom();
      if (drag.handle === "left" || drag.handle === "tl" || drag.handle === "bl") adjustLeft();
      if (drag.handle === "right" || drag.handle === "tr" || drag.handle === "br") adjustRight();

      const next = { top, left, width, height };
      rectRef.current = next;
      paint(next, false);
      setRect(next);
    },
    [paint]
  );

  const onEdgePointerUp = useCallback(() => {
    if (!dragRef.current) return;
    dragRef.current = null;
    setDragging(false);
    commit(rectRef.current, false);
  }, [commit]);

  const nudge = useCallback(
    (handle: EdgeHandle, deltaPx: number) => {
      const s = rectRef.current;
      let { top, left, width, height } = s;
      if (handle === "top") {
        const newTop = clamp(s.top + deltaPx, 0, s.top + s.height - MIN_SIZE);
        height = s.height - (newTop - s.top);
        top = newTop;
      } else if (handle === "bottom") {
        height = clamp(s.height + deltaPx, MIN_SIZE, DISPLAY_H - s.top);
      } else if (handle === "left") {
        const newLeft = clamp(s.left + deltaPx, 0, s.left + s.width - MIN_SIZE);
        width = s.width - (newLeft - s.left);
        left = newLeft;
      } else {
        width = clamp(s.width + deltaPx, MIN_SIZE, DISPLAY_W - s.left);
      }
      commit({ top, left, width, height }, false);
    },
    [commit]
  );

  const onSliderKeyDown = useCallback(
    (handle: EdgeHandle) => (e: ReactKeyboardEvent) => {
      const step = e.shiftKey ? 10 : 1;
      if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
        e.preventDefault();
        nudge(handle, -step);
      } else if (e.key === "ArrowDown" || e.key === "ArrowRight") {
        e.preventDefault();
        nudge(handle, step);
      }
    },
    [nudge]
  );

  const readout = useMemo(() => {
    const w = Math.round(rect.width * NATURAL_SCALE);
    const h = Math.round(rect.height * NATURAL_SCALE);
    return `${w} x ${h} — ${activePreset}`;
  }, [rect, activePreset]);

  const edgeAria = (handle: EdgeHandle, label: string, value: number, max: number) => ({
    role: "slider" as const,
    tabIndex: 0,
    "aria-label": label,
    "aria-valuemin": 0,
    "aria-valuemax": Math.round(max * NATURAL_SCALE),
    "aria-valuenow": Math.round(value * NATURAL_SCALE),
    onKeyDown: onSliderKeyDown(handle),
  });

  return (
    <div className={`w-full max-w-md ${className}`}>
      <style>{CSS}</style>
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </span>

      <div
        ref={stageRef}
        className="ns-mc-stage relative select-none overflow-hidden rounded-[12px] border border-border"
        style={{ width: DISPLAY_W, height: DISPLAY_H }}
        onPointerMove={onEdgePointerMove}
        onPointerUp={onEdgePointerUp}
        onPointerCancel={onEdgePointerUp}
      >
        <img src={PHOTO_SRC} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full" />

        {/* rule-of-thirds grid, visible only while dragging */}
        <div
          ref={windowRef}
          aria-hidden="true"
          className={`ns-mc-window pointer-events-none absolute ${dragging ? "ns-mc-grid" : ""}`}
        />

        {/* mat boards */}
        <div ref={topMatRef} className="ns-mc-mat absolute inset-x-0 top-0 border-b border-border bg-background" />
        <div ref={bottomMatRef} className="ns-mc-mat absolute inset-x-0 border-t border-border bg-background" />
        <div ref={leftMatRef} className="ns-mc-mat absolute left-0 border-r border-border bg-background" />
        <div ref={rightMatRef} className="ns-mc-mat absolute border-l border-border bg-background" />

        {/* edge grip strips — drag + keyboard sliders */}
        <div
          {...edgeAria("top", "Top edge", rect.top, DISPLAY_H)}
          onPointerDown={onEdgePointerDown("top")}
          className="ns-mc-grip ns-mc-grip-h absolute inset-x-0 cursor-ns-resize"
          style={{ top: rect.top - 4 }}
        />
        <div
          {...edgeAria("bottom", "Bottom edge", DISPLAY_H - rect.top - rect.height, DISPLAY_H)}
          onPointerDown={onEdgePointerDown("bottom")}
          className="ns-mc-grip ns-mc-grip-h absolute inset-x-0 cursor-ns-resize"
          style={{ top: rect.top + rect.height - 4 }}
        />
        <div
          {...edgeAria("left", "Left edge", rect.left, DISPLAY_W)}
          onPointerDown={onEdgePointerDown("left")}
          className="ns-mc-grip ns-mc-grip-v absolute inset-y-0 cursor-ew-resize"
          style={{ left: rect.left - 4 }}
        />
        <div
          {...edgeAria("right", "Right edge", DISPLAY_W - rect.left - rect.width, DISPLAY_W)}
          onPointerDown={onEdgePointerDown("right")}
          className="ns-mc-grip ns-mc-grip-v absolute inset-y-0 cursor-ew-resize"
          style={{ left: rect.left + rect.width - 4 }}
        />

        {/* corner handles */}
        {(
          [
            ["tl", rect.left - 5, rect.top - 5, "nwse-resize"],
            ["tr", rect.left + rect.width - 5, rect.top - 5, "nesw-resize"],
            ["bl", rect.left - 5, rect.top + rect.height - 5, "nesw-resize"],
            ["br", rect.left + rect.width - 5, rect.top + rect.height - 5, "nwse-resize"],
          ] as [CornerHandle, number, number, string][]
        ).map(([handle, left, top, cursor]) => (
          <div
            key={handle}
            aria-hidden="true"
            onPointerDown={onEdgePointerDown(handle)}
            className="ns-mc-corner absolute h-2.5 w-2.5"
            style={{ left, top, cursor }}
          />
        ))}
      </div>

      <div className="mt-4 flex items-center justify-between gap-4">
        <div className="flex gap-1.5" role="group" aria-label="Crop ratio presets">
          {RATIO_PRESETS.map((p) => (
            <button
              key={p.label}
              type="button"
              onClick={() => applyPreset(p.label, p.ratio)}
              aria-pressed={activePreset === p.label}
              className={`rounded-[6px] border px-2.5 py-1 font-mono text-[11px] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent ${
                activePreset === p.label
                  ? "border-foreground bg-foreground text-background"
                  : "border-border text-ns-muted hover:border-foreground hover:text-foreground"
              }`}
            >
              {p.label}
            </button>
          ))}
        </div>
        <p className={`ns-mc-readout-${autoId} font-mono text-xs text-ns-muted`}>{readout}</p>
      </div>
    </div>
  );
}

// Abstract monochrome placeholder "photo" (mountains + sun), fixed content
// tones — a photo's own pixels aren't chrome, so they're exempt from the
// theme-token rule the surrounding UI follows.
const PHOTO_SRC = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="480" height="300" viewBox="0 0 480 300"><rect width="480" height="300" fill="#3a3a3a"/><circle cx="380" cy="70" r="34" fill="#565656"/><polygon points="0,300 140,140 230,230 300,120 480,300" fill="#2a2a2a"/><polygon points="0,300 90,200 180,300" fill="#232323"/></svg>'
)}`;

const CSS = `
.ns-mc-mat{ box-shadow: inset 0 0 10px rgba(0,0,0,0.35); }
.ns-mc-window.ns-mc-grid::before, .ns-mc-window.ns-mc-grid::after{ content:""; position:absolute; background: rgba(255,255,255,0.35); }
.ns-mc-window::before{ left: 33.333%; top:0; bottom:0; width:1px; }
.ns-mc-window::after{ left: 66.666%; top:0; bottom:0; width:1px; }
.ns-mc-grip{ z-index: 5; }
.ns-mc-grip-h{ height: 8px; }
.ns-mc-grip-v{ width: 8px; }
.ns-mc-grip-h::after{ content:""; position:absolute; left:0; right:0; top:50%; height:0; border-top: 2px solid transparent; transform: translateY(-1px); }
.ns-mc-grip-v::after{ content:""; position:absolute; top:0; bottom:0; left:50%; width:0; border-left: 2px solid transparent; transform: translateX(-1px); }
.ns-mc-grip:hover.ns-mc-grip-h::after, .ns-mc-grip:focus-visible.ns-mc-grip-h::after{ border-top-color: var(--ns-accent); }
.ns-mc-grip:hover.ns-mc-grip-v::after, .ns-mc-grip:focus-visible.ns-mc-grip-v::after{ border-left-color: var(--ns-accent); }
.ns-mc-grip:focus-visible{ outline: none; }
.ns-mc-corner{ z-index: 6; }
.ns-mc-corner:hover{ background: var(--ns-accent); border-radius: 9999px; }
@media (prefers-reduced-motion: reduce){
  .ns-mc-mat, .ns-mc-window{ transition: none !important; }
}
`;

export default MatCrop;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/image-crop-mat.tsx, so point the import there.

Use when cropping an image by sliding four physical mat boards over it, snapping to ratio presets with a settle and showing thirds guides only while dragging, so the crop reads as framing rather than as a dashed selection rectangle; it is the wrong choice for comparing two images against each other (compare-crack-seam) and for panning a large canvas inside a viewport (minimap-pantograph).

Install

npx shadcn add https://design.helpmarq.com/r/image-crop-mat.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build an image-crop control styled as a physical passe-partout (picture-frame mat), not a dashed-rectangle overlay with corner squares. A fixed-size stage (e.g. 480x300 display px, representing a photo scaled down from a larger 'real' resolution — pick a NATURAL_SCALE constant, e.g. 4x, and multiply every display-px measurement by it for the readout and the `onChange` payload, so the numbers look like a real photo's pixel dimensions rather than tiny CSS px values) holds a placeholder photo (a small inline monochrome SVG landscape rendered via a real `<img src="data:image/svg+xml,...">`, alt="", aria-hidden — never `dangerouslySetInnerHTML` for static markup, and never a literal photo asset dependency) and four mat boards (`bg-background` panels — real theme tokens, since the mats are UI chrome even though the photo's own pixels are exempt content) positioned top/right/bottom/left to cover everything outside the current crop rect, each with a hairline border on its inner edge and a soft inset box-shadow reads as pressing gently onto the photo. Crop state is one rect `{top, left, width, height}` in display px. Each mat's size is derived from it (top mat height = rect.top, bottom mat spans from rect.top+rect.height to the stage bottom, etc.) and painted via direct ref `style` writes, not by re-rendering four positioned divs from React on every pixel of drag — React state updates on drag are fine for cheap re-renders elsewhere (the readout text), but the four mat elements' actual position must be imperative writes so dragging never taxes React's reconciler per pointermove. Dragging: a thin invisible 'grip' strip sits along each edge (and a small square at each corner, combining two edges) as the actual pointer-drag target — not the mat itself, so the hit area stays a predictable few px regardless of mat size. During an active drag, tracking is 1:1 with the pointer (a deliberate simplification — true continuous 'paper friction' physics on every pointermove was cut for scope; if revisited, exponential-smoothing the painted rect toward the raw pointer target each rAF tick would add it). Ratio presets (1:1, 4:5, 16:9, Free — real buttons, `aria-pressed` reflecting the currently-matching ratio) DO get the explicit 'settle' the brief calls for: fit the largest centered rect at that ratio within the stage, then paint it with a short (~180ms) no-overshoot ease-out transition (a stand-in for 'critically damped, no bounce' — an actual spring simulation is unnecessary to read as non-bouncy). Free does not force a resize, it just relabels. Reduced motion drops that transition — every preset and drag-release lands instantly. The crop window shows a rule-of-thirds grid (two vertical hairlines via CSS pseudo-elements, never SVG dashes) ONLY while a drag is in progress (a `dragging` boolean toggling a class) — it must not be visible at rest. Readout: a Geist Mono line below the stage reads like `1240 x 775 — 16:9` (or 'Free' when the current rect doesn't match a preset within a small tolerance), computed from the NATURAL-scaled width/height. Keyboard: each edge doubles as a labeled `role="slider"` (`aria-label` 'Top edge'/'Right edge'/etc., `aria-valuemin/max/now` in natural-scale units, `tabIndex=0`) layered on the same grip strip element used for pointer dragging. Arrow keys nudge that edge by 1 display px, Shift+Arrow by 10 — for simplicity every edge responds to both axis pairs (ArrowUp/Left decrease, ArrowDown/Right increase) rather than requiring the 'correct' physical axis per edge, since the meaningful accessibility contract is 'reliably nudge each edge independently by a known amount,' not which specific key increases vs decreases. Hover on a grip strip reveals a thin (2px) `var(--ns-accent)` line along that edge via a CSS `:hover`/`:focus-visible` rule (no JS), plus the matching resize cursor (`ns-resize` for top/bottom, `ew-resize` for left/right, corner cursors for corners). `onChange` fires with `{x, y, width, height, ratio}` in natural-scale units on every committed change — preset click, drag release, and keyboard nudge — not continuously mid-drag. No dependencies.

Props

PropTypeDefaultDescription
onChange?(rect: CropRect) => void—called with the new crop rect on every drag/resize
className?string—extra classes merged onto the rendered root element