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/cropperManual
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 };