Skip to content
omaris

Inputs

Image Cropper

Crop an image by moving the image, not the box.

import { ImageCropper } from 'omaris'
Learn

Examples

Basic

The window stands still and the picture moves under it — drag it, scroll to zoom, pinch on a touchscreen. It is constrained, so an empty corner inside the frame is not a thing that can happen.

Sunset over hills

Loading…

<script lang="ts">	import { ImageCropper } from 'omaris';​	/** A drawn picture, so the demo needs no network. */	const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(`		<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800">			<defs>				<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">					<stop offset="0%" stop-color="#1b3a5c"/>					<stop offset="60%" stop-color="#e9825c"/>					<stop offset="100%" stop-color="#f3c98b"/>				</linearGradient>			</defs>			<rect width="1200" height="800" fill="url(#sky)"/>			<circle cx="820" cy="300" r="90" fill="#fff3d6"/>			<path d="M0 620 L260 460 L470 620 Z" fill="#12283d"/>			<path d="M330 620 L620 400 L900 620 Z" fill="#0d1f30"/>			<rect y="620" width="1200" height="180" fill="#08151f"/>		</svg>`)}`;</script>​<ImageCropper src={PHOTO} alt="Sunset over hills" aspect={16 / 9} class="w-full max-w-lg" />

Ratios

ratios puts a row of aspect presets under the stage and rebinds aspect — pick one and the window reshapes, the picture re-covers it, nothing else moves. resizable adds grips to the window, so a locked ratio can still be scaled and a free one shaped by hand.

Sunset over hills

Loading…

aspect: 1.78
<script lang="ts">	import { ImageCropper, Text, type ImageCropperAspect } from 'omaris';​	const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(`		<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800">			<rect width="1200" height="800" fill="#12233b"/>			<circle cx="880" cy="240" r="110" fill="#fbbf24"/>			<path d="M0 640 L300 420 L560 640 Z" fill="#1f4d3f"/>			<path d="M380 640 L700 380 L1000 640 Z" fill="#14332b"/>			<rect y="640" width="1200" height="160" fill="#0a1a26"/>		</svg>`)}`;​	let aspect = $state<ImageCropperAspect>(16 / 9);</script>​<div class="flex w-full max-w-lg flex-col gap-2">	<ImageCropper		bind:aspect		src={PHOTO}		alt="Sunset over hills"		ratios		resizable		height={260}		controls={['zoom', 'reset']}	/>​	<Text variant="label-sm" tone="muted" font="mono">		aspect: {aspect === 'free' ? 'free' : aspect.toFixed(2)}	</Text></div>

Exporting

The export replays exactly the transform you see, at whatever output size you name — so what lands in the blob is what was in the frame. onchange reports the rectangle in source pixels every time the crop settles.

Avatar source

Loading…

<script lang="ts">	import { Button, ImageCropper, Text, type CropRect } from 'omaris';​	const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(`		<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900">			<rect width="900" height="900" fill="#0f172a"/>			<circle cx="450" cy="380" r="180" fill="#38bdf8"/>			<rect x="180" y="600" width="540" height="180" rx="40" fill="#f472b6"/>		</svg>`)}`;​	let cropper = $state<ImageCropper | null>(null);	let output = $state<string | null>(null);	let rect = $state<CropRect | null>(null);</script>​<div class="flex w-full flex-col items-center gap-3">	<ImageCropper		bind:this={cropper}		src={PHOTO}		alt="Avatar source"		aspect={1}		shape="circle"		onchange={(next) => (rect = next)}		class="w-full max-w-sm"	/>​	<div class="flex items-center gap-3">		<Button			size="sm"			onclick={() => (output = cropper?.toDataURL('image/png', undefined, 160) ?? null)}		>			Export at 160px		</Button>		{#if output}			<img src={output} alt="The exported crop" class="size-16 rounded-full" />		{/if}	</div>​	{#if rect}		<Text variant="label-sm" tone="muted" font="mono">			{Math.round(rect.width)} × {Math.round(rect.height)} at {Math.round(rect.x)}, {Math.round(				rect.y			)}		</Text>	{/if}</div>

Adjusting

controls takes the list you want, so straighten adds a fine-angle slider next to the quarter-turn button. Any angle constrains as tightly as a right one: tilt the picture and it grows to keep the window full.

Two blocks on a horizon

Loading…

0°
<script lang="ts">	import { ImageCropper, Text } from 'omaris';​	const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(`		<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="700">			<rect width="1000" height="700" fill="#0f172a"/>			<rect x="80" y="120" width="380" height="460" rx="24" fill="#38bdf8"/>			<rect x="540" y="240" width="380" height="340" rx="24" fill="#f472b6"/>			<path d="M0 620 L1000 560" stroke="#e2e8f0" stroke-width="8"/>		</svg>`)}`;​	let rotation = $state(0);	let flipX = $state(false);</script>​<div class="flex w-full max-w-md flex-col gap-2">	<ImageCropper		bind:rotation		bind:flipX		src={PHOTO}		alt="Two blocks on a horizon"		aspect={4 / 3}		height={240}		controls={['zoom', 'straighten', 'rotate', 'flip', 'reset']}	/>​	<Text variant="label-sm" tone="muted" font="mono">		{Math.round(rotation)}°{flipX ? ' · mirrored' : ''}	</Text></div>

Overridden

height is a custom property, so classes.stage can replace it outright. controls={false} hands the zoom to a slider of your own through the bindable zoom, and children puts content over the stage.

Hills

Loading…

Cover photo
Zoom
<script lang="ts">	import { ImageCropper, Slider, Text } from 'omaris';​	const PHOTO = `data:image/svg+xml;utf8,${encodeURIComponent(`		<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600">			<rect width="1000" height="600" fill="#0b3d2e"/>			<circle cx="300" cy="220" r="120" fill="#facc15"/>			<path d="M0 520 L340 300 L700 520 Z" fill="#065f46"/>			<path d="M420 520 L760 340 L1000 520 Z" fill="#064e3b"/>		</svg>`)}`;​	let zoom = $state(1.4);</script>​<div class="flex w-full max-w-md flex-col gap-3">	<ImageCropper		src={PHOTO}		alt="Hills"		aspect={3 / 1}		bind:zoom		grid={false}		controls={false}		inset={0.94}		height="auto"		classes={{ stage: 'h-40 rounded-shape-lg', window: 'rounded-shape-md' }}	>		<Text variant="label-sm" class="absolute start-3 top-3 text-white/80">Cover photo</Text>	</ImageCropper>​	<Slider bind:value={zoom} min={1} max={4} step={0.05} label="Zoom" /></div>

When to use it

Use it for

  • An avatar: aspect={1}, shape="circle", and toBlob() on save. The output is square; the circle is only the preview.
  • A cover or banner at a fixed ratio, or ratios when one picture has to fit several places.
  • Straightening a photo: controls={['zoom', 'straighten', 'reset']} adds a fine-angle slider. The picture still cannot show a gap.
  • A crop the person shapes by hand: aspect="free" with resizable.

Not for

  • Looking at a picture, zooming in, swiping to the next → Image Viewer.
  • Choosing the file. It takes a src; your own file input or drop zone hands it URL.createObjectURL(file).
  • Filters, drawing, annotations → nothing here. It only crops.
  • The thumbnail once the crop is done → Avatar.

Do

  • bind:this and call toBlob or toFile when the person confirms. The export replays the on-screen transform at the size you name.
  • Name the output size the server wants: toBlob({ width: 512 }). Do not take the crop at the source's own pixel size.
  • Set crossorigin="anonymous" on a remote image, or the canvas is tainted and the export throws.
  • Give it alt, and put "Choose a different photo" in children, over the stage.

Don't

  • Turn controls off without binding zoom to a control of your own. Pinch and wheel still work, but the slider is the keyboard's way in.
  • Use it when the crop must show blank space around a small image. It keeps the window full by design.
  • Size the stage with a class on the root. height is a property; classes.stage replaces it.

Quick reference

shape
  • rect (default)
  • circle

API

ImageCropper

Crop an image by moving the image, not the box.

The crop window sits still in the middle at the aspect ratio you asked for, and the picture pans and zooms underneath it — the gesture every phone camera has trained everyone to expect. Zooming happens under the pointer: the pixel beneath the cursor, or between two fingers, is the one that stays put.

The picture is constrained: it can never be moved or zoomed far enough to leave a gap inside the window, so an empty corner is not a thing that can happen — at any angle, not only at quarter turns. The export replays exactly the transform you see, at whatever output size you name, so what lands in the blob is what was in the frame.

The window itself can be sized — ratios puts a row of aspect presets under the stage, resizable puts handles on the window, and aspect="free" lets the two of them shape it freely.

import { ImageCropper } from 'omaris'
<ImageCropper bind:this={cropper} src={file} aspect={1} shape="circle" /><Button onclick={async () => upload(await cropper.toBlob())}>Save</Button>

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
stage
The dark stage the picture moves around in.
image
The picture. Only ever moved with a compositor transform.
window
The crop window. Everything outside it is dimmed by one enormous spread shadow, so the scrim is a single element that can never fall out of register with the hole in it.
grid
Rule-of-thirds lines inside the window.
gridLine
No description in the source yet.
handle
A corner or edge grip on the window. Only rendered when the cropper is resizable; each one is a real button, so the window can be sized from the keyboard too.
ratios
The row of aspect-ratio presets.
ratio
No description in the source yet.
controls
Zoom slider and the buttons beside it.
slider
No description in the source yet.
angle
The straighten row: a fine-angle slider and its readout.
angleValue
No description in the source yet.
button
No description in the source yet.
loading
Shown until the picture has loaded.

Props

src required
string

Image URL, object URL or data URL.

alt

Defaults to ''

string

Describes the picture, for anyone who cannot see it.

aspect bindable

Defaults to 1

ImageCropperAspect

Width ÷ height of the crop window. 1 is a square, 'free' is any. Bindable.

shape

Defaults to 'rect'

ImageCropperShape
rect
circle
zoom bindable

Defaults to 1

number

Zoom, where 1 is "just covers the window". Bindable.

rotation bindable

Defaults to 0

number

Rotation in degrees — any angle, not only quarter turns. Bindable.

flipX bindable

Defaults to false

boolean

Mirror the picture left to right. Bindable.

flipY bindable

Defaults to false

boolean

Mirror the picture top to bottom. Bindable.

maxZoom

Defaults to 5

number
grid

Defaults to true

boolean

Draw the rule-of-thirds grid while the crop is being changed.

controls

Defaults to true

boolean | ImageCropperControl[]

The controls under the stage: true for the usual set, false for none, or the list you want — ['zoom', 'straighten', 'reset'].

ratios

Defaults to false

boolean | ImageCropperRatio[]

A row of aspect presets over the controls. true for the built-in set, or your own — [{ label: 'Cover', value: 3 }].

resizable

Defaults to false

boolean

Put handles on the window so its size can be dragged.

inset

Defaults to 0.86

number

Widest the crop window may be drawn, as a share of the stage. Leaves room around it so there is somewhere to drag from, and caps how far the handles can be pulled.

crossorigin
'anonymous' | 'use-credentials'

Set for a cross-origin image that has to be exported. Without it the canvas is tainted and toBlob throws.

height

Defaults to '20rem'

number | string

Height of the stage. A number is px, a string any CSS length — a property rather than a class, so classes.stage can replace it.

onchange
(crop: CropRect) => void

Fires whenever the crop settles, with the rectangle in source pixels.

children
Snippet

Extra content over the stage — a "choose a different photo" button.

class
string
classes
ImageCropperClasses