Vitral 0.2
Media

Cropper

A crop rectangle over an image, with no dependency: lock a ratio, mask a circle, rotate and flip, and crop on a server or in the browser.

Import

main.ts
import { Cropper } from '@vitral/vue';

Avatar

A circle locks the ratio to one and drops the handles: drag the crop, or zoom it. The preview beside it is the same picture under a smaller box, not a second render.

A portrait

Use the arrow keys to move the crop, Shift with an arrow to resize it, Home and End for the corners.

<script setup lang="ts">import { Button, Cropper, type CropValue, StackPanel } from '@vitral/vue';import { ref, useTemplateRef } from 'vue'; const portrait = 'https://picsum.photos/id/1027/900/1200';const avatar = ref<CropValue>({ x: 0, y: 0, width: 0, height: 0, rotate: 0, flipX: false, flipY: false }); // The file, when the file is what you want: a canvas, then a Blob to upload.const cropper = useTemplateRef<InstanceType<typeof Cropper>>('avatarCropper');const saved = ref<string | null>(null);const savedSize = ref(0); async function save() {    const blob = await cropper.value?.toBlob('image/png', { width: 256 });    if (!blob) return;    if (saved.value) URL.revokeObjectURL(saved.value);    saved.value = URL.createObjectURL(blob);    savedSize.value = blob.size;}</script> <template>    <StackPanel spacing="1rem" style="flex: 1 1 100%; min-width: 0">        <Cropper ref="avatarCropper" v-model="avatar" :src="portrait" shape="circle" alt="A portrait" height="18rem" :preview-size="96" />        <StackPanel orientation="horizontal" spacing="0.625rem" align="center" wrap>            <Button label="Crop to a 256px PNG" size="small" @click="save" />            <small v-if="saved" style="color: var(--vt-text-muted-color)">{{ Math.round(savedSize / 1024) }} kB</small>            <img v-if="saved" :src="saved" alt="The cropped avatar" width="48" height="48" style="border-radius: 50%" />        </StackPanel>    </StackPanel></template>

A ratio, with handles

Eight handles for the pointer, hidden from assistive technology: the rectangle itself is the one tab stop.

A landscape

Use the arrow keys to move the crop, Shift with an arrow to resize it, Home and End for the corners.

{ x: 0, y: 0, width: 0, height: 0, rotate: 0 }
<script setup lang="ts">import { Cropper, type CropValue } from '@vitral/vue';import { ref } from 'vue'; const photo = 'https://picsum.photos/id/1015/1600/900';const banner = ref<CropValue>({ x: 0, y: 0, width: 0, height: 0, rotate: 0, flipX: false, flipY: false }); const rounded = (value: CropValue) => `{ x: ${Math.round(value.x)}, y: ${Math.round(value.y)}, width: ${Math.round(value.width)}, height: ${Math.round(value.height)}, rotate: ${value.rotate} }`;</script> <template>    <Cropper v-model="banner" :src="photo" aspect="16:9" alt="A landscape" height="18rem" />    <small style="color: var(--vt-text-muted-color)">{{ rounded(banner) }}</small></template>

Without the thirds

The guide is on for a rectangle and off for a circle, where a rule of thirds means nothing. `grid` overrules either way.

A landscape

Use the arrow keys to move the crop, Shift with an arrow to resize it, Home and End for the corners.

<script setup lang="ts">import { Cropper, type CropValue } from '@vitral/vue';import { ref } from 'vue'; const photo = 'https://picsum.photos/id/1015/1600/900';const plain = ref<CropValue>({ x: 0, y: 0, width: 0, height: 0, rotate: 0, flipX: false, flipY: false });</script> <template>    <Cropper v-model="plain" :src="photo" aspect="4:3" :grid="false" alt="A landscape" height="16rem" /></template>

Free, with ratios and turns

A chooser locks the ratio; the quarter turns and the flips travel with the crop, so it keeps pointing at the same part of the picture.

A landscape

Use the arrow keys to move the crop, Shift with an arrow to resize it, Home and End for the corners.

const crop = ref<CropValue>({ x: 0, y: 0, width: 0, height: 0, rotate: 0 });
<script setup lang="ts">import { Cropper, type CropValue } from '@vitral/vue';import { computed, ref } from 'vue'; const photo = 'https://picsum.photos/id/1015/1600/900';const free = ref<CropValue>({ x: 0, y: 0, width: 0, height: 0, rotate: 0, flipX: false, flipY: false }); const aspects = [    { label: 'Free', value: 'free' },    { label: '1:1', value: '1:1' },    { label: '4:3', value: '4:3' },    { label: '16:9', value: '16:9' }]; const model = computed(() => {    const { x, y, width, height, rotate } = free.value;    return `const crop = ref<CropValue>({ x: ${Math.round(x)}, y: ${Math.round(y)}, width: ${Math.round(width)}, height: ${Math.round(height)}, rotate: ${rotate} });`;});</script> <template>    <Cropper v-model="free" :src="photo" :aspects="aspects" rotatable alt="A landscape" height="20rem" />    <code style="font-size: 0.75rem; color: var(--vt-text-muted-color)">{{ model }}</code></template>

In a dialog

Where a cropper usually lives: a photograph is chosen, cropped over the page, and the profile changes only once it is accepted. The dialog gives the stage its width, so the crop is measured against what it is actually shown at.

AF
<script setup lang="ts">import { Avatar, Button, Cropper, Dialog, StackPanel, type CropValue } from '@vitral/vue';import { ref, useTemplateRef } from 'vue'; // The whole flow, which is where a cropper usually lives: a picture is// chosen, cropped in a dialog, and only then does the profile change.const portrait = 'https://picsum.photos/id/1027/900/1200';const dialog = ref(false);const pending = ref<CropValue>({ x: 0, y: 0, width: 0, height: 0, rotate: 0, flipX: false, flipY: false });const profile = ref<string | null>(null);const modalCropper = useTemplateRef<InstanceType<typeof Cropper>>('modalCropper'); async function apply() {    const blob = await modalCropper.value?.toBlob('image/png', { width: 192 });    dialog.value = false;    if (!blob) return;    if (profile.value) URL.revokeObjectURL(profile.value);    profile.value = URL.createObjectURL(blob);}</script> <template>    <StackPanel orientation="horizontal" spacing="0.625rem" align="center" wrap>        <Avatar :image="profile ?? undefined" :label="profile ? undefined : 'AF'" shape="circle" size="large" :alt="profile ? 'Your photo' : undefined" />        <Button :label="profile ? 'Change the photo' : 'Upload a photo'" severity="secondary" variant="outlined" size="small" @click="dialog = true" />        <small v-if="profile" style="color: var(--vt-text-muted-color)">Cropped to a 192px PNG</small>    </StackPanel>     <Dialog v-model:visible="dialog" modal header="Crop your photo" :style="{ width: 'min(30rem, 92vw)' }">        <Cropper ref="modalCropper" v-model="pending" :src="portrait" shape="circle" alt="The photograph being cropped" height="16rem" />        <template #footer>            <Button label="Cancel" severity="secondary" variant="text" @click="dialog = false" />            <Button label="Use this photo" @click="apply" />        </template>    </Dialog></template>

The shape of it

A portrait

Use the arrow keys to move the crop, Shift with an arrow to resize it, Home and End for the corners.

<script setup lang="ts">import { Button, Cropper, type CropValue } from '@vitral/vue';import { ref, useTemplateRef } from 'vue'; const src = 'https://picsum.photos/id/1027/900/1200';const crop = ref<CropValue>({ x: 0, y: 0, width: 0, height: 0, rotate: 0, flipX: false, flipY: false });const cropper = useTemplateRef<InstanceType<typeof Cropper>>('cropper'); // The file, when you want the file rather than the numbers.async function upload() {    const blob = await cropper.value?.toBlob('image/png', { width: 256 });    if (blob) console.log(blob);}</script> <template>    <Cropper ref="cropper" v-model="crop" :src="src" shape="circle" :preview-size="96" alt="A portrait" />    <Button label="Get the file" size="small" @click="upload" /></template>

API

Read from packages/vue/src/components/Cropper/types.ts, so it says what the component actually accepts.

Props

NameTypeDescription
srcstringThe image. Anything an `<img>` takes; a data or object URL included.
altstringDescribes the picture, not the control. Without one the image is decorative.
aspectnumber | string | nullLock the crop to a ratio: `1`, `'16:9'`, or null to leave it free.
aspectsreadonly CropAspect[]Ratios to offer in the toolbar. Omit it and no chooser is drawn.
shape'rect' | 'circle'`'circle'` masks the crop and the preview, and locks the ratio to 1.
handlesbooleanDraw the eight resize handles. Defaults to true for a rectangle, false for a circle.
rotatablebooleanOffer the quarter turns and the flips.
zoomablebooleanOffer a zoom slider.
gridbooleanDraw the thirds over the crop while it is being moved. On by default for a rectangle and off for a circle, where a rule of thirds means nothing: the guide is for composing a frame, and a round crop has no corners to compose towards.
minWidthnumberThe smallest crop, in the image's own pixels.
minHeightnumber—
heightnumber | stringThe stage's height: a number is pixels.
previewSizenumberThe live preview's size, in pixels. Zero draws none.
disabledboolean—

Plus pt, dt and unstyled from BaseProps, see pass-through and unstyled mode.

Emits

EventPayloadDescription
update:modelValuevalue: CropValueThe crop, as it changes.
loadevent: { width: number; height: number }The image has loaded and its natural size is known.
changevalue: CropValueA drag of the crop or a handle has finished.

Slots

NameSlot propsDescription
toolbar(props: { value: CropValue; reset: () => void })Replaces the toolbar.
preview(props: { value: CropValue })Beside the preview, or in place of it.