Custom stencil
This tutorial builds a circle that moves by dragging its body and resizes around its centre from a single control. Its implementation uses public geometry and gesture primitives.
Register the stencil
Call getCropperContext() during component initialization. Register a getter before checking whether cropper state exists. The getter supplies aspectRatio: 1 and BoundingBoxType.Circle; the cropper combines those options with its constraint strategy before the first reset. Registration automatically cleans up on destruction.
The context exposes a stable cropper API, reactive disabled, and the currently displayed image. Read changing values with $derived. An enclosing cropper’s disabled state must reach every gesture surface.
Position and draw
getStencilCoordinates() supplies layout coordinates to StencilWrapper; getTransitions() keeps its movement aligned with the image. StencilOverlay shades outside the circle. The demo’s CSS supplies the round outline and positions the resize control.
Move and resize
Two DraggableArea instances handle the body and the resize control. They install native non-passive listeners through attachments. The body forwards only the displacement to moveCoordinates; forwarding the native event as its second argument would incorrectly put it in the method’s options position.
The resize callback uses the center anchor and horizontal displacement for both dimensions. Moving right grows the circle evenly; moving left shrinks it. The registered ratio keeps it square.
<script lang="ts">
import { MoveDiagonal } from '@lucide/svelte';
import {
DraggableArea,
getCropperContext,
StencilOverlay,
StencilWrapper,
type MoveDirections
} from 'svelte-advanced-cropper';
import { BoundingBoxType } from 'advanced-cropper/extensions/fit-to-image';
const context = getCropperContext();
const cropper = context.cropper;
context.registerStencil(() => ({ aspectRatio: 1, boundingBox: BoundingBoxType.Circle }));
const disabled = $derived(context.disabled);
const state = $derived(cropper.getState());
const coordinates = $derived(cropper.getStencilCoordinates());
const transitions = $derived(cropper.getTransitions());
function resize(shift: MoveDirections) {
// Only the horizontal shift is used, on both axes: the square stencil grows evenly.
cropper.resizeCoordinates('center', {
left: shift.left,
top: shift.left
});
}
</script>
{#if state}
<StencilWrapper class="circle-stencil" {transitions} {...coordinates}>
<DraggableArea
{disabled}
class="circle-stencil__handler"
onMove={resize}
onMoveEnd={cropper.resizeCoordinatesEnd}
>
<MoveDiagonal color="white" size={22} />
</DraggableArea>
<DraggableArea
{disabled}
class="circle-stencil__draggable-area"
onMove={(directions) => cropper.moveCoordinates(directions)}
onMoveEnd={cropper.moveCoordinatesEnd}
>
<StencilOverlay class="circle-stencil__overlay" />
</DraggableArea>
</StencilWrapper>
{/if}
<style>
:global(.circle-stencil) {
cursor: move;
}
:global(.circle-stencil__handler) {
position: absolute;
right: 15%;
top: 14%;
z-index: 1;
cursor: ne-resize;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(50%, -50%);
}
:global(.circle-stencil__overlay) {
border: dashed 2px white;
box-sizing: border-box;
border-radius: 50%;
}
:global(.circle-stencil__draggable-area) {
width: 100%;
height: 100%;
}
</style>Each gesture calls its matching completion method: moveCoordinatesEnd for the body and resizeCoordinatesEnd for the handle. These end interaction state and allow pending reconciliation to run. When composing BoundingBox controls instead, preserve the native event in onMove forwarding so modifier-key behaviour survives.
Use it as a child
Drag the circle, then its diagonal control. The cropper can configure core restrictions normally; the custom stencil owns only its presentation and interaction rules. See stencil context for lifecycle details and Print guides for reactive, product-specific options.