Inputs
Image Cropper
Crop an image by moving the image, not the box.
import { ImageCropper } from 'omaris' 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.
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.
Loading…
<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.
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.
Loading…
<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.
Loading…
Cover photo<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", andtoBlob()on save. The output is square; the circle is only the preview. - A cover or banner at a fixed ratio, or
ratioswhen 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"withresizable.
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 itURL.createObjectURL(file). - Filters, drawing, annotations → nothing here. It only crops.
- The thumbnail once the crop is done → Avatar.
Do
-
bind:thisand calltoBlobortoFilewhen 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" inchildren, over the stage.
Don't
- Turn
controlsoff without bindingzoomto 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.
heightis a property;classes.stagereplaces 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 bindableDefaults to 1
ImageCropperAspect Width ÷ height of the crop window. 1 is a square, 'free' is any. Bindable.
shape Defaults to 'rect'
ImageCropperShape rectcircle
zoom bindableDefaults to 1
number Zoom, where 1 is "just covers the window". Bindable.
rotation bindableDefaults to 0
number Rotation in degrees — any angle, not only quarter turns. Bindable.
flipX bindableDefaults to false
boolean Mirror the picture left to right. Bindable.
flipY bindableDefaults 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