Core engine for pinch-to-zoom, pan and rotate experiences on any canvas-like content. Framework-agnostic JavaScript library.
Play with the demo: https://zoompinch.pages.dev
npm install @zoompinch/core<!DOCTYPE html>
<html>
<head>
<style>
#wrapper {
width: 800px;
height: 600px;
border: 1px solid #ddd;
touch-action: none;
overflow: hidden;
position: relative;
}
.canvas {
display: inline-block;
will-change: transform;
}
.matrix {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="wrapper">
<div class="canvas">
<img width="1536" height="2048" src="https://imagedelivery.net/mudX-CmAqIANL8bxoNCToA/489df5b2-38ce-46e7-32e0-d50170e8d800/public" />
</div>
<div class="matrix">
<svg width="100%" height="100%">
<circle id="centerMarker" r="8" fill="red" />
</svg>
</div>
</div>
<script type="module">
import { Zoompinch } from '@zoompinch/core';
const wrapper = document.getElementById('wrapper');
// Initialize engine
const engine = new Zoompinch(
wrapper,
{ top: 0, left: 0, right: 0, bottom: 0 }, // offset
0, // translateX
0, // translateY
1, // scale
0, // rotate
0.5, // minScale
4, // maxScale
false, // clampBounds
true // rotation
);
// Set up event listeners
wrapper.addEventListener('wheel', (e) => engine.handleWheel(e));
wrapper.addEventListener('mousedown', (e) => engine.handleMousedown(e));
window.addEventListener('mousemove', (e) => engine.handleMousemove(e));
window.addEventListener('mouseup', (e) => engine.handleMouseup(e));
wrapper.addEventListener('touchstart', (e) => engine.handleTouchstart(e));
window.addEventListener('touchmove', (e) => engine.handleTouchmove(e));
window.addEventListener('touchend', (e) => engine.handleTouchend(e));
wrapper.addEventListener('gesturestart', (e) => engine.handleGesturestart(e));
window.addEventListener('gesturechange', (e) => engine.handleGesturechange(e));
window.addEventListener('gestureend', (e) => engine.handleGestureend(e));
// Listen for events
engine.addEventListener('init', () => {
console.log('Initialized, canvas size:', engine.canvasBounds);
// Center canvas
engine.applyTransform(1, [0.5, 0.5], [0.5, 0.5]);
});
engine.addEventListener('update', () => {
console.log('Transform:', {
translateX: engine.translateX,
translateY: engine.translateY,
scale: engine.scale,
rotate: engine.rotate
});
// Update matrix overlay
updateMatrix();
});
// Handle clicks
wrapper.addEventListener('click', (e) => {
const [x, y] = engine.normalizeClientCoords(e.clientX, e.clientY);
console.log('Canvas position:', x, y);
});
function updateMatrix() {
const marker = document.getElementById('centerMarker');
const [cx, cy] = engine.composePoint(
engine.canvasBounds.width / 2,
engine.canvasBounds.height / 2
);
marker.setAttribute('cx', cx);
marker.setAttribute('cy', cy);
}
// Clean up when done
// engine.destroy();
</script>
</body>
</html>new Zoompinch(
element: HTMLElement,
offset: Offset,
translateX: number,
translateY: number,
scale: number,
rotate: number,
minScale?: number,
maxScale?: number,
clampBounds?: boolean,
rotation?: boolean
)Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
element |
HTMLElement |
- | Wrapper element (must contain .canvas child) |
offset |
Offset |
- | Inner padding: { top, right, bottom, left } |
translateX |
number |
- | Initial X translation in pixels |
translateY |
number |
- | Initial Y translation in pixels |
scale |
number |
- | Initial scale factor |
rotate |
number |
- | Initial rotation in radians |
minScale |
number |
0.1 |
Minimum scale (user gestures only) |
maxScale |
number |
10 |
Maximum scale (user gestures only) |
clampBounds |
boolean |
false |
Clamp panning within bounds (user gestures only) |
rotation |
boolean |
true |
Enable rotation gestures |
HTML Structure Required:
<div id="wrapper">
<div class="canvas">
<!-- Your content here -->
</div>
</div>Note: minScale, maxScale, rotation, and clampBounds only apply during user interaction. Direct property changes are unrestricted.
Access and modify transform state:
engine.translateX // number - X translation
engine.translateY // number - Y translation
engine.scale // number - Scale factor
engine.rotate // number - Rotation in radians
engine.minScale // number - Minimum scale
engine.maxScale // number - Maximum scale
engine.clampBounds // boolean - Clamp bounds flag
engine.rotation // boolean - Rotation enabled flag
engine.offset // Offset - Inner padding objectRead-only properties:
engine.canvasBounds // Bounds - Canvas dimensions: { x, y, width, height }
engine.wrapperBounds // Bounds - Wrapper dimensions: { x, y, width, height }
engine.naturalScale // number - Scale to fit canvas in wrapperThe engine extends EventTarget and emits two events:
| Event | Description |
|---|---|
init |
Fired when canvas dimensions are available |
update |
Fired when transform changes |
engine.addEventListener('init', () => {
console.log('Canvas ready:', engine.canvasBounds);
});
engine.addEventListener('update', () => {
console.log('Transform:', engine.translateX, engine.translateY, engine.scale, engine.rotate);
});Apply transform by anchoring a canvas point to a wrapper point.
Parameters:
scale: number- Target scalewrapperCoords: [number, number]- Wrapper position (0-1, 0.5 = center)canvasCoords: [number, number]- Canvas position (0-1, 0.5 = center)
Examples:
// Center canvas at scale 1
engine.applyTransform(1, [0.5, 0.5], [0.5, 0.5]);
// Zoom to 2x, keep centered
engine.applyTransform(2, [0.5, 0.5], [0.5, 0.5]);
// Anchor canvas top-left to wrapper center
engine.applyTransform(1.5, [0.5, 0.5], [0, 0]);Convert global client coordinates to canvas coordinates.
Parameters:
clientX: number- Global X from eventclientY: number- Global Y from event
Returns: [number, number] - Canvas coordinates in pixels
Example:
wrapper.addEventListener('click', (e) => {
const [x, y] = engine.normalizeClientCoords(e.clientX, e.clientY);
console.log('Canvas position:', x, y);
});Convert canvas coordinates to wrapper coordinates (accounts for transform).
Parameters:
x: number- Canvas X in pixelsy: number- Canvas Y in pixels
Returns: [number, number] - Wrapper coordinates in pixels
Example:
// Get wrapper position for canvas center
const [wrapperX, wrapperY] = engine.composePoint(
engine.canvasBounds.width / 2,
engine.canvasBounds.height / 2
);Rotate canvas around a specific canvas point.
Parameters:
x: number- Canvas X (rotation center)y: number- Canvas Y (rotation center)radians: number- Rotation angle
Example:
// Rotate 90° around canvas center
const centerX = engine.canvasBounds.width / 2;
const centerY = engine.canvasBounds.height / 2;
engine.rotateCanvas(centerX, centerY, Math.PI / 2);Manually trigger a transform update and render.
// Modify transform
engine.translateX = 100;
engine.translateY = 50;
engine.scale = 2;
// Apply changes
engine.update();Set translation with optional clamping based on clampBounds setting.
Parameters:
x: number- X translationy: number- Y translation
Example:
engine.setTranslateFromUserGesture(100, 50);
engine.update();Clean up the engine and remove internal observers.
engine.destroy();Handle user input by calling these methods:
wrapper.addEventListener('wheel', (e) => engine.handleWheel(e));
wrapper.addEventListener('mousedown', (e) => engine.handleMousedown(e));
window.addEventListener('mousemove', (e) => engine.handleMousemove(e));
window.addEventListener('mouseup', (e) => engine.handleMouseup(e));wrapper.addEventListener('touchstart', (e) => engine.handleTouchstart(e));
window.addEventListener('touchmove', (e) => engine.handleTouchmove(e));
window.addEventListener('touchend', (e) => engine.handleTouchend(e));wrapper.addEventListener('gesturestart', (e) => engine.handleGesturestart(e));
window.addEventListener('gesturechange', (e) => engine.handleGesturechange(e));
window.addEventListener('gestureend', (e) => engine.handleGestureend(e));Absolute pixels within canvas content.
- Origin:
(0, 0)at top-left - Range:
0tocanvasBounds.width,0tocanvasBounds.height
const [canvasX, canvasY] = engine.normalizeClientCoords(event.clientX, event.clientY);Absolute pixels within viewport/wrapper.
- Origin:
(0, 0)at top-left (accounting for offset) - Range:
0towrapperBounds.width,0towrapperBounds.height
const [wrapperX, wrapperY] = engine.composePoint(canvasX, canvasY);Normalized coordinates for applyTransform.
- Range:
0.0to1.0 0.5= center,1.0= bottom-right
[0, 0] // top-left
[0.5, 0.5] // center
[1, 1] // bottom-rightConversion Flow:
Client Coords → normalizeClientCoords() → Canvas Coords → composePoint() → Wrapper Coords
-
Required HTML structure:
<div id="wrapper"> <div class="canvas"> <!-- content --> </div> </div>
-
Required CSS:
#wrapper { touch-action: none; overflow: hidden; position: relative; } .canvas { will-change: transform; }
-
Attach event listeners to window for mouse/touch move/end:
wrapper.addEventListener('mousedown', ...); window.addEventListener('mousemove', ...); // window, not wrapper window.addEventListener('mouseup', ...); // window, not wrapper
-
Center content on init:
engine.addEventListener('init', () => { engine.applyTransform(1, [0.5, 0.5], [0.5, 0.5]); });
-
Clean up when done:
engine.destroy();
// Direct property manipulation
engine.translateX = 100;
engine.translateY = 50;
engine.scale = 2;
engine.rotate = Math.PI / 4;
// Apply changes
engine.update();// Get wrapper inner dimensions
const innerWidth = engine.wrapperInnerWidth;
const innerHeight = engine.wrapperInnerHeight;
// Get natural scale (scale to fit)
const fitScale = engine.naturalScale;// Enable/disable clamping
engine.clampBounds = true;
// Use clamp-aware setter
engine.setTranslateFromUserGesture(translateX, translateY);
engine.update();- ✅ Chrome/Edge (latest)
- ✅ Firefox (latest)
- ✅ Safari (latest, including iOS)
- ✅ Mobile browsers (iOS Safari, Chrome Mobile)
MIT
- @zoompinch/vue - Vue 3
- @zoompinch/elements - Web Components
- @zoompinch/core - Core engine
Built with ❤️ by Elya Maurice Conrad