Cropper
A composable image-crop primitive. The Cropper root manages pan/zoom/rotation state in context; CropperCanvas renders the interactive canvas; CropperOverlay draws the crop mask. The consumer wires their own Dialog, Slider, and Buttons around it.
Install
Pull Cropper from the barrel for everyday use, or the granular path when you want a tighter bundle.
import { Cropper } from "@brika/clay";import { Cropper } from "@brika/clay/components/cropper";A minimal example
Drop this into a page. Native HTML attributes pass through to the underlying primitive.
'use client';
import * as React from 'react';
import { useState } from 'react';
import { Card, CardContent } from '@brika/clay/components/card';
import {
Cropper,
CropperApply,
CropperCancel,
CropperFallback,
CropperFlip,
CropperInput,
CropperReset,
CropperRotate,
CropperViewport,
CropperZoom,
} from '@brika/clay/components/cropper';
import {
FlipHorizontal2,
FlipVertical2,
ImageUp,
RotateCcw,
RotateCcwSquare,
RotateCw,
Upload,
ZoomIn,
ZoomOut,
} from 'lucide-react';
/**
* Full-featured Cropper in uncontrolled mode: viewport with drag-drop fallback, zoom slider, rotate/flip/reset toolbar, and apply/cancel actions, all composed inside a Card.
*/
export default function CropperDefaultDemo() {
const [preview, setPreview] = useState<string | null>(null);
function onCrop(blob: Blob) {
if (preview != null) URL.revokeObjectURL(preview);
setPreview(URL.createObjectURL(blob));
}
return (
<div className="flex flex-col items-center gap-4">
{preview != null && (
<img src={preview} alt="Cropped result" className="size-20 rounded-full object-cover" />
)}
<Card>
<CardContent className="flex flex-col items-center gap-3 pt-4">
<Cropper>
<CropperViewport>
<CropperFallback>
<ImageUp className="size-8 opacity-60" />
<span className="text-xs font-medium">Drop a photo or click upload</span>
</CropperFallback>
</CropperViewport>
{/* Zoom row: minus icon, slider, plus icon */}
<div className="flex w-full items-center gap-3">
<ZoomOut className="size-4 shrink-0 text-muted-foreground" />
<CropperZoom className="flex-1" />
<ZoomIn className="size-4 shrink-0 text-muted-foreground" />
</div>
{/* Toolbar: rotate, flip, reset, upload */}
<div className="flex w-full items-center gap-2">
<CropperRotate direction="left" aria-label="Rotate left" className="flex-1">
<RotateCcw className="size-4" />
</CropperRotate>
<CropperRotate direction="right" aria-label="Rotate right" className="flex-1">
<RotateCw className="size-4" />
</CropperRotate>
<CropperFlip axis="h" aria-label="Flip horizontal" className="flex-1">
<FlipHorizontal2 className="size-4" />
</CropperFlip>
<CropperFlip axis="v" aria-label="Flip vertical" className="flex-1">
<FlipVertical2 className="size-4" />
</CropperFlip>
<CropperReset aria-label="Reset" className="flex-1">
<RotateCcwSquare className="size-4" />
</CropperReset>
<CropperInput variant="outline" size="icon-sm" aria-label="Upload image" className="flex-1">
<Upload className="size-4" />
</CropperInput>
</div>
{/* Action row */}
<div className="flex w-full gap-2">
<CropperCancel className="flex-1">Cancel</CropperCancel>
<CropperApply onCrop={onCrop} className="flex-1">Apply</CropperApply>
</div>
</Cropper>
</CardContent>
</Card>
</div>
);
}Cropper, every way
Inline
Minimal inline Cropper (no modal) with a rounded crop shape: choose a file, drag to reposition, zoom, then apply.
Modal
Avatar-upload flow: clicking the avatar opens the file browser, then the crop UI appears inside a Dialog composed from public Cropper parts with a full rotate/flip/zoom toolbar.
Accessibility
- The crop canvas is pointer-only; keyboard-accessible zoom and apply actions must be provided by the consuming layout (e.g. a Slider and Button).
- Wrap the entire crop UI with an accessible dialog so screen readers can announce the modal context.
Theme tokens
Every CSS variable Cropper reads, with its default and the
dotted path you'd write in a ThemeConfig JSON
to override it. Set any of these in your theme to retune Cropper
without touching component code.
Cropper tokens
5 tokens--cropper-stage-bgcolorvar(--accent)components.cropper.stageBgBackground fill of the crop canvas stage, visible as letterboxing when the image does not cover the full square.
--cropper-overlay-colorcoloroklch(0.08 0.01 60 / 0.42)components.cropper.overlayColorDim color applied outside the crop area via a large box-shadow on the crop mask. Defaults to a near-black at ~42% opacity so the outside of the circle is visibly darkened.
--cropper-overlay-bordercoloroklch(1 0 0 / 0.45)components.cropper.overlayBorderBorder drawn on the inner edge of the crop mask to delineate the crop area. Defaults to 45% white.
--cropper-drop-active-ringcolorvar(--primary)components.cropper.dropActiveRingOutline ring color applied to CropperViewport while an image file is dragged over it. Uses the brand primary so it matches the theme accent.
--cropper-placeholder-colorcolorvar(--muted-foreground)components.cropper.placeholderColorIcon and text color for the empty-state placeholder shown inside CropperViewport when no image has been loaded yet.
Override in a theme
Authoring a theme is plain JSON. Drop overrides under
components.cropper, the names
are camelCase versions of the variable suffix.
{
"id": "my-theme",
"name": "My Theme",
"description": "...",
"accentSwatches": ["#000"],
"components": {
"cropper": {
"stageBg": "var(--accent)"
}
}
}
Then apply with applyTheme(myTheme)
(see the theming guide),
scope it to a subtree with themeToCssVars(myTheme, mode),
or consume any token directly in your own JSX with the matching Tailwind utility
(e.g. bg-cropper-…) — see the
chips on each token row.
API reference
Props specific to Cropper. Native HTML attributes pass through to the underlying primitive, see the component source on GitHub for the full type signature.
- File | nullSeed file for uncontrolled mode. Only read once on mount; ignored when
imageis provided (controlled mode). Use this to preload a sample image in demos or to restore a previously saved file; subsequent picks and drag-drops still work without any additional wiring. - File | nullThe file to display in the cropper (controlled mode). When omitted the cropper manages its own file state internally (uncontrolled mode) and
CropperInput/ drag-drop onCropperViewportload files without any consumer state. Passimagewhen you need to control the loaded file from outside (e.g. a Dialog that clears on cancel). - numberMaximum zoom level. Defaults to 3. CropperZoom's Slider max defaults to this value.
- numberMinimum zoom level. Defaults to 1 (image just covers the stage). CropperZoom's Slider min defaults to this value.
- (file: File) => voidCalled whenever a new file is loaded via
CropperInputpick or drag-drop onCropperViewport, in both controlled and uncontrolled mode. In controlled mode (imageprop provided) this is the primary wiring point: wireonImageChange={setFile}so picks and drops actually replace the displayed image (without it, controlled mode ignores new files because the consumer ownsimage). In uncontrolled mode this is an optional notification callback; the cropper already updates its internal state automatically. - 'circle' | 'rounded'Mask shape rendered by CropperOverlay:
'circle'or'rounded'. Defaults to'circle'. - numberOn-screen stage size in px. Defaults to 288.