Apply a pinch-and-zoom experience that feels native and communicates the transform reactively and lets you project any layer on top of the transformed canvas. Framework-agnostic core with official bindings for Vue 3 and Web Components.
Play with the demo: https://zoompinch.pages.dev

Unlike other libraries, Zoompinch does not just uses the center point between two fingers as projection center. The fingers get correctly projected on the virtual canvas. This makes pinching on touch devices feel native-like.
Adside of touch, mouse and wheel events, gesture events (Safari Desktop) are supported as well! Try it out on the demo
translateX, translateY, scale, rotatenpm install @zoompinch/vue
<template>
<zoompinch
ref="zoompinchRef"
v-model:transform="transform"
:offset="{ top: 0, right: 0, bottom: 0, left: 0 }"
:min-scale="0.5"
:max-scale="4"
:clamp-bounds="false"
:rotation="true"
:zoom-speed="1"
:translate-speed="1"
:zoom-speed-apple-trackpad="1"
:translate-speed-apple-trackpad="1"
:mouse="false"
:wheel="true"
:touch="true"
:gesture="true"
@init="handleInit"
@click="handleClick"
>
<img width="1536" height="2048" src="https://imagedelivery.net/mudX-CmAqIANL8bxoNCToA/489df5b2-38ce-46e7-32e0-d50170e8d800/public" />
<template #matrix="{ composePoint, normalizeClientCoords, canvasWidth, canvasHeight }">
<svg width="100%" height="100%">
<circle :cx="composePoint(canvasWidth / 2, canvasHeight / 2)[0]" :cy="composePoint(canvasWidth / 2, canvasHeight / 2)[1]" r="8" fill="red" />
</svg>
</template>
</zoompinch>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Zoompinch } from '@zoompinch/vue';
import '@zoompinch/vue/style.css';
const zoompinchRef = ref<InstanceType<typeof Zoompinch>>();
const transform = ref({
translateX: 0,
translateY: 0,
scale: 1,
rotate: 0
});
function handleInit() {
// Center canvas on initialization
zoompinchRef.value?.applyTransform(1, [0.5, 0.5], [0.5, 0.5], 0);
}
function handleTransformUpdate(newTransform) {
console.log('Transform updated:', newTransform);
}
function handleClick(event: MouseEvent) {
if (!zoompinchRef.value) return;
const [x, y] = zoompinchRef.value.normalizeClientCoords(event.clientX, event.clientY);
console.log('Cliked at', x, y);
}
</script>
<style scoped>
.zoompinch {
width: 800px;
height: 600px;
border: 1px solid #f00;
}
</style>
npm install @zoompinch/react
import React, { useRef, useState } from 'react';
import { Zoompinch, ZoompinchRef } from '@zoompinch/react';
function App() {
const zoompinchRef = useRef<ZoompinchRef>(null);
const [transform, setTransform] = useState({
translateX: 0,
translateY: 0,
scale: 1,
rotate: 0
});
function handleInit() {
// Center canvas on initialization
zoompinchRef.current?.applyTransform(1, [0.5, 0.5], [0.5, 0.5], 0);
}
function handleTransformChange(newTransform) {
console.log('Transform updated:', newTransform);
}
function handleClick(event: React.MouseEvent) {
if (!zoompinchRef.current) return;
const [x, y] = zoompinchRef.current.normalizeClientCoords(event.clientX, event.clientY);
console.log('Clicked at canvas position:', x, y);
}
return (
<Zoompinch
ref={zoompinchRef}
style={{ width: '800px', height: '600px', border: '1px solid #ccc' }}
transform={transform}
onTransformChange={handleTransformChange}
offset={{ top: 0, right: 0, bottom: 0, left: 0 }}
minScale={0.5}
maxScale={4}
clampBounds={false}
rotation={true}
zoomSpeed={1}
translateSpeed={1}
zoomSpeedAppleTrackpad={1}
translateSpeedAppleTrackpad={1}
mouse={true}
wheel={true}
touch={true}
gesture={true}
onInit={handleInit}
onClick={handleClick}
matrix={({ composePoint, normalizeClientCoords, canvasWidth, canvasHeight }) => {
const [x, y] = composePoint(100, 100);
return (
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
<circle cx={x} cy={y} r="5" fill="#f00" />
</svg>
);
}}
>
<img
width="1536"
height="2048"
src="https://imagedelivery.net/mudX-CmAqIANL8bxoNCToA/489df5b2-38ce-46e7-32e0-d50170e8d800/public"
draggable={false}
style={{ userSelect: 'none' }}
/>
</Zoompinch>
);
}
export default App;
npm install @zoompinch/elements
<!DOCTYPE html>
<html>
<head>
<script type="module">
import '@zoompinch/elements';
</script>
<style>
zoom-pinch {
display: block;
width: 800px;
height: 600px;
}
</style>
</head>
<body>
<zoom-pinch id="zoomPinch" clamp-bounds="true">
<img width="1536" height="2048" src="https://imagedelivery.net/mudX-CmAqIANL8bxoNCToA/489df5b2-38ce-46e7-32e0-d50170e8d800/public" />
<svg slot="matrix" width="100%" height="100%">
<circle id="marker" r="8" fill="red" />
</svg>
</zoom-pinch>
<script type="module">
const zoomPinch = document.getElementById('zoomPinch');
zoomPinch.addEventListener('init', () => {
zoomPinch.applyTransform(1, [0.5, 0.5], [0.5, 0.5]);
});
zoomPinch.addEventListener('update', () => {
const [cx, cy] = zoomPinch.composePoint(
zoomPinch.canvasWidth / 2,
zoomPinch.canvasHeight / 2
);
document.getElementById('marker').setAttribute('cx', cx);
document.getElementById('marker').setAttribute('cy', cy);
});
</script>
</body>
</html>
→ Full Web Components Documentation
| Package | Description | Links |
|---|---|---|
| @zoompinch/core | Core engine (framework-agnostic) | README · npm |
| @zoompinch/vue | Vue 3 | README · npm |
| @zoompinch/react | React | README · npm |
| @zoompinch/elements | Web Components | README · npm |
All implementations share the same core API:
// Apply transform by anchoring points
applyTransform(scale, wrapperCoords, canvasCoords, rotate?)
// Convert client coordinates to canvas coordinates
normalizeClientCoords(clientX, clientY)
// Convert canvas coordinates to wrapper coordinates
composePoint(x, y)
// Rotate canvas around a point
rotateCanvas(x, y, radians)
{
translateX: number, // X translation in pixels
translateY: number, // Y translation in pixels
scale: number, // Scale factor (1 = 100%)
rotate: number // Rotation in radians
}
init - Fired when canvas dimensions are availableupdate - Fired when transform changes@zoompinch uses transform-origin: 0,0 (top-left corner) internally. This avoids assumptions about canvas size, which can be dynamic or lazy-loaded.
To center content, wait for the init event and call applyTransform():
// Center canvas at scale 1
applyTransform(1, [0.5, 0.5], [0.5, 0.5]);
This ensures the engine stays predictable and doesn't make layout decisions on your behalf.
The library uses three coordinate systems:
Absolute pixels within canvas content.
- Range: 0 to canvasWidth, 0 to canvasHeight
Absolute pixels within viewport/wrapper.
- Range: 0 to wrapperWidth, 0 to wrapperHeight
Normalized coordinates for applyTransform.
- 0.5 = center, 1.0 = bottom-right
Conversion:
Client → normalizeClientCoords() → Canvas → composePoint() → Wrapper
For custom integrations, use the core engine directly:
npm install @zoompinch/core
→ Full Core Engine Documentation
MIT
Contributions welcome! Please open an issue or PR on GitHub.
Built with ❤️ by Elya Maurice Conrad
$ claude mcp add zoompinch \
-- python -m otcore.mcp_server <graph>