Aura Design System

Cropper

A draggable crop region you can also nudge with the arrow keys.

Preview

Loading...
import { Cropper } from "@/components/ui/Cropper";

export function CropperDemo() {
  return <Cropper />;
}

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/cropper

Manual

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the Cropper component into your components/ui/Cropper.tsx file.

"use client";

import * as React from "react";
import { cn } from "@/utils/class-names";

type Crop = { x: number; y: number; w: number; h: number };

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

function Cropper({
  className,
  label = "Crop image",
}: {
  className?: string;
  label?: string;
}) {
  const frame = React.useRef<HTMLDivElement>(null);
  const [crop, setCrop] = React.useState<Crop>({ x: 0.22, y: 0.18, w: 0.46, h: 0.5 });
  const drag = React.useRef<{ x: number; y: number; crop: Crop } | null>(null);

  const move = (next: Crop) => {
    setCrop({
      ...next,
      x: clamp(next.x, 0, 1 - next.w),
      y: clamp(next.y, 0, 1 - next.h),
    });
  };

  return (
    <div
      ref={frame}
      data-slot="cropper"
      role="group"
      aria-label={label}
      className={cn("relative h-16 w-full overflow-hidden rounded-sm bg-gray-3", className)}
    >
      <div className="absolute inset-y-0 start-0 w-1/2 bg-accent-4" />
      <div
        role="slider"
        tabIndex={0}
        aria-label="Crop region"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(crop.x * 100)}
        aria-valuetext={`${Math.round(crop.x * 100)}% across, ${Math.round(crop.y * 100)}% down`}
        className="absolute cursor-grab border-2 border-gray-12 bg-gray-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8"
        style={{
          left: `${crop.x * 100}%`,
          top: `${crop.y * 100}%`,
          width: `${crop.w * 100}%`,
          height: `${crop.h * 100}%`,
        }}
        onPointerDown={(event) => {
          event.currentTarget.setPointerCapture(event.pointerId);
          drag.current = { x: event.clientX, y: event.clientY, crop };
        }}
        onPointerMove={(event) => {
          if (!drag.current || !frame.current) return;
          const rect = frame.current.getBoundingClientRect();
          move({
            ...drag.current.crop,
            x: drag.current.crop.x + (event.clientX - drag.current.x) / rect.width,
            y: drag.current.crop.y + (event.clientY - drag.current.y) / rect.height,
          });
        }}
        onPointerUp={() => {
          drag.current = null;
        }}
        onKeyDown={(event) => {
          const step = event.shiftKey ? 0.08 : 0.02;
          if (event.key === "ArrowLeft") {
            event.preventDefault();
            move({ ...crop, x: crop.x - step });
          } else if (event.key === "ArrowRight") {
            event.preventDefault();
            move({ ...crop, x: crop.x + step });
          } else if (event.key === "ArrowUp") {
            event.preventDefault();
            move({ ...crop, y: crop.y - step });
          } else if (event.key === "ArrowDown") {
            event.preventDefault();
            move({ ...crop, y: crop.y + step });
          }
        }}
      />
    </div>
  );
}

export { Cropper };