use-zoom-pinch 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,83 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ For pre-1.0 releases (`0.x.y`), minor version bumps may include breaking changes.
8
+
9
+ ## [0.4.0] — 2026-07-13
10
+
11
+ ### Breaking
12
+
13
+ These changes affect `resetView`, the keyboard `0` key, and `doubleTap` modes
14
+ `"reset"` / `"toggle"`. Apps that relied on the previous hardcoded reset target
15
+ should adjust.
16
+
17
+ - **`resetView()` now returns to `initialViewState`** instead of the hardcoded
18
+ `{ x: 0, y: 0, zoom: 1, rotation: 0 }`. If `initialViewState` is not provided,
19
+ the default `{ 0, 0, 1 }` is still used.
20
+ - **Keyboard `0`** resets to `initialViewState` (was: hardcoded reset).
21
+ - **`doubleTap` `"reset"` and `"toggle"`** modes animate back to `initialViewState`,
22
+ preserving the initial `x` / `y` / `zoom` / `rotation`. Previously they reset to
23
+ `{ 0, 0, 1 }` and silently dropped rotation.
24
+
25
+ ### Added
26
+
27
+ - **`setView(view, { skipConstraints: true })`** — bypasses bounds clamping, axis
28
+ locking, and snap-to-grid for precise programmatic positioning (even outside
29
+ bounds). Only affects `setView`; gestures are always constrained.
30
+ - **`activationKeys.rotate`** is now honored (previously declared in the type but
31
+ ignored). Rotation via touch pinch, the Safari `GestureEvent`, and keyboard
32
+ `[` / `]` is gated by the configured activation key.
33
+ - **`cursor.zooming`** is now applied while wheel-zooming, and the cursor restores
34
+ to `idle` once the debounced zoom-end fires (previously the option was unused).
35
+ - **Exported math helpers** `clamp`, `distance`, `angleBetween` for manual
36
+ coordinate conversion.
37
+ - **Debounced `ResizeObserver`** (150ms) for `fitToContent` auto-resize, so window
38
+ resize no longer yanks the viewport on every intermediate frame.
39
+ - **Unified `onTransformEnd`** now fires after `doubleTap` and after **inertia
40
+ fully settles** (previously it fired only after pan/zoom/pinch end, and during
41
+ inertia it fired too early at pointer-up).
42
+
43
+ ### Fixed
44
+
45
+ - **Wheel `preventDefault` ordering** — the wheel handler no longer calls
46
+ `preventDefault()` when it will not handle the event. Previously, with
47
+ `activationKeys.zoom` set but the key not held, or with the relevant gesture
48
+ disabled, the hook still suppressed the browser's native wheel behavior
49
+ (page scroll, ctrl+wheel page zoom).
50
+ - **`animateTo` with `duration <= 0`** no longer poisons the view with `NaN`.
51
+ It now jumps to the target immediately.
52
+ - **`activationKeys` no longer "stick"** when the user alt-tabs while holding a
53
+ modifier — pressed keys are cleared on `window blur` and `visibilitychange`.
54
+ - **`contextmenu` is suppressed** when `panButton: 2` (right mouse), making
55
+ right-button panning usable.
56
+
57
+ ### Tests
58
+
59
+ - Added `src/__tests__/bugfixes.test.ts` with 27 tests covering all fixes above,
60
+ including wheel `preventDefault` ordering, the duration guard, `skipConstraints`,
61
+ blur-clear, and `onTransformEnd` consistency after double-tap and inertia.
62
+ - Fixed a dead test for rotation snap (it had no assertions); replaced with a real
63
+ pinch-gesture-based assertion.
64
+ - Updated existing tests to match the new `resetView` / `initialViewState` semantics.
65
+
66
+ ## [0.3.0]
67
+
68
+ ### Added
69
+
70
+ - Bounds bounce (rubber-band overscroll with snap-back), axis locking, cursor
71
+ management, `wheelMode`, rotation snap levels, activation keys, `onTransformEnd`,
72
+ navigation helpers (`panTo`, `panBy`, `zoomTo`, `fitToRect`), double-tap zoom,
73
+ inertia, animated transitions, controlled mode.
74
+ - Test coverage thresholds and `eslint-plugin-react-hooks`.
75
+ - README improvements and bundle-size badge.
76
+
77
+ ### Fixed
78
+
79
+ - Initial release bug fixes.
80
+
81
+ ## [0.1.0]
82
+
83
+ - Initial release: `useZoomPinch` React hook.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 nemezzizz
3
+ Copyright (c) 2026 NemeZZiZZ
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,19 +1,46 @@
1
1
  # useZoomPinch
2
2
 
3
- Lightweight React hook for **pan**, **pinch-to-zoom**, and **scroll zoom** with trackpad and touch support. Zero dependencies beyond React.
3
+ Lightweight React hook for **pan**, **pinch-to-zoom**, **rotation**, and **scroll zoom** with trackpad and touch support. Zero dependencies beyond React.
4
+
5
+ [![npm](https://img.shields.io/npm/v/use-zoom-pinch)](https://www.npmjs.com/package/use-zoom-pinch)
6
+ [![CI](https://github.com/NemeZZiZZ/use-zoom-pinch/actions/workflows/ci.yml/badge.svg)](https://github.com/NemeZZiZZ/use-zoom-pinch/actions/workflows/ci.yml)
7
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/use-zoom-pinch)](https://bundlephobia.com/package/use-zoom-pinch)
4
8
 
5
9
  [**Live Demo**](https://nemezzizz.github.io/use-zoom-pinch/)
10
+ [**Changelog**](./CHANGELOG.md)
6
11
 
7
12
  ## Features
8
13
 
9
14
  - **Scroll to pan** — mouse wheel and trackpad two-finger scroll
10
15
  - **Pinch to zoom** — trackpad pinch (via `ctrlKey` + wheel) and multi-touch pinch on mobile
11
16
  - **Pointer drag** — mouse drag and single-touch drag for panning
17
+ - **Rotation** — two-finger rotation with `rotateTo`/`rotateBy` methods
18
+ - **Gesture toggles** — enable/disable pan, zoom, and rotate independently
19
+ - **Double-tap zoom** — configurable toggle/zoomIn/reset modes
20
+ - **Inertia** — momentum-based pan with configurable friction
21
+ - **Animated transitions** — smooth easing for programmatic view changes
22
+ - **Bounds** — constrain panning within rectangular limits
23
+ - **Keyboard navigation** — arrow keys, +/-, rotation with [ / ]
24
+ - **Coordinate conversion** — `screenToContent` / `contentToScreen`
25
+ - **Zoom snap levels** — snap to predefined zoom levels on gesture end
26
+ - **Snap to grid** — snap positions to a grid (continuous or on end)
27
+ - **Auto-fit content** — `fitToContent` with ResizeObserver auto-resize
28
+ - **Configurable pan button** — left, middle, or right mouse button
29
+ - **Bounce at bounds** — rubber-band overscroll with snap-back animation
30
+ - **Axis locking** — restrict pan to horizontal or vertical only
31
+ - **Cursor management** — automatic grab/grabbing cursor on drag
32
+ - **Wheel mode** — swap default: wheel zooms, ctrl+wheel pans
33
+ - **Rotation constraints** — min/max angle and rotation snap levels
34
+ - **Activation keys** — require Shift/Alt/Ctrl for specific gestures
35
+ - **`onTransformEnd`** — unified callback after any gesture ends
36
+ - **Navigation** — `panTo`, `panBy`, `zoomTo`, `fitToRect` for precise viewport control
12
37
  - **Controlled & uncontrolled** modes
38
+ - **Granular events** — `onPanStart/End`, `onZoomStart/End`, `onPinchStart/End`, `onRotateStart/End`
39
+ - **Event filtering** — `shouldHandleEvent` to exclude interactive elements
13
40
  - **Stable listeners** — config changes don't re-register event listeners
14
41
  - **TypeScript-first** with full type exports
15
42
  - **Tree-shakeable** ESM + CJS dual build
16
- - **~2 KB** minified + gzipped
43
+ - **~5.8 KB** minified + gzipped
17
44
 
18
45
  ## Installation
19
46
 
@@ -59,7 +86,7 @@ function ControlledCanvas() {
59
86
  const containerRef = useRef<HTMLDivElement>(null)
60
87
  const [viewState, setViewState] = useState<ViewState>({ x: 0, y: 0, zoom: 1 })
61
88
 
62
- const { view, centerZoom, resetView } = useZoomPinch({
89
+ const { view, zoomIn, zoomOut, resetView } = useZoomPinch({
63
90
  containerRef,
64
91
  viewState,
65
92
  onViewStateChange: setViewState,
@@ -67,9 +94,9 @@ function ControlledCanvas() {
67
94
 
68
95
  return (
69
96
  <div>
70
- <button onClick={() => centerZoom(view.zoom * 1.5)}>Zoom In</button>
71
- <button onClick={() => centerZoom(view.zoom / 1.5)}>Zoom Out</button>
72
- <button onClick={resetView}>Reset</button>
97
+ <button onClick={() => zoomIn(1.5, { animate: true })}>Zoom In</button>
98
+ <button onClick={() => zoomOut(1.5, { animate: true })}>Zoom Out</button>
99
+ <button onClick={() => resetView({ animate: true })}>Reset</button>
73
100
 
74
101
  <div
75
102
  ref={containerRef}
@@ -89,43 +116,463 @@ function ControlledCanvas() {
89
116
  }
90
117
  ```
91
118
 
119
+ ## Animated Transitions
120
+
121
+ All programmatic methods accept an optional `AnimationOptions` parameter:
122
+
123
+ ```tsx
124
+ const {
125
+ setView,
126
+ centerZoom,
127
+ resetView,
128
+ zoomIn,
129
+ zoomOut,
130
+ zoomToElement,
131
+ panTo,
132
+ panBy,
133
+ zoomTo,
134
+ fitToRect,
135
+ } = useZoomPinch({
136
+ containerRef,
137
+ })
138
+
139
+ // Instant (default, backward-compatible)
140
+ setView({ x: 100, y: 200, zoom: 2 })
141
+
142
+ // Animated
143
+ setView({ x: 100, y: 200, zoom: 2 }, { animate: true, duration: 300 })
144
+
145
+ // Skip bounds/axis/snap constraints (precise positioning even outside bounds)
146
+ setView({ x: 1000, y: 1000, zoom: 2 }, { skipConstraints: true })
147
+
148
+ // With custom easing
149
+ import { easeInOut } from "use-zoom-pinch"
150
+ resetView({ animate: true, duration: 500, easing: easeInOut })
151
+
152
+ // Zoom to a specific element
153
+ const elRef = useRef<HTMLDivElement>(null)
154
+ zoomToElement(elRef.current!, 2, { animate: true })
155
+ ```
156
+
157
+ ### Built-in Easings
158
+
159
+ - `linear` — constant speed
160
+ - `easeOut` — fast start, slow end (default)
161
+ - `easeInOut` — slow start, fast middle, slow end
162
+
163
+ ### Skipping Constraints
164
+
165
+ `setView` normally applies bounds clamping, axis locking, and snap-to-grid. Pass `skipConstraints: true` to bypass them when you need precise programmatic positioning (even outside bounds):
166
+
167
+ ```tsx
168
+ setView({ x: 9999, y: 9999, zoom: 2 }, { skipConstraints: true })
169
+ ```
170
+
171
+ ### Exported Helpers
172
+
173
+ The library also exports a few pure math helpers for manual coordinate work:
174
+
175
+ ```ts
176
+ import { clamp, distance, angleBetween } from "use-zoom-pinch"
177
+
178
+ clamp(15, 0, 10) // 10
179
+ distance(0, 0, 3, 4) // 5
180
+ angleBetween(0, 0, 0, 10) // 90 (degrees)
181
+ ```
182
+
183
+ ## Navigation
184
+
185
+ ```tsx
186
+ const { panTo, panBy, zoomTo, fitToRect } = useZoomPinch({ containerRef })
187
+
188
+ // Center on a point in content space
189
+ panTo(500, 300, { animate: true })
190
+
191
+ // Shift viewport by 100px right, 50px down (screen-space delta)
192
+ panBy(100, 50)
193
+
194
+ // Zoom to 3x centered on a content-space point
195
+ zoomTo(3, { x: 500, y: 300 }, { animate: true })
196
+
197
+ // Fit a region into view with padding
198
+ fitToRect({ x: 100, y: 100, width: 400, height: 300 }, { animate: true, padding: 20 })
199
+ ```
200
+
201
+ ## Rotation
202
+
203
+ Two-finger rotation is detected during pinch gestures. Disabled by default — enable via `gestures`:
204
+
205
+ ```tsx
206
+ const { view, rotateTo, rotateBy } = useZoomPinch({
207
+ containerRef,
208
+ gestures: { rotate: true },
209
+ })
210
+
211
+ // Apply with CSS:
212
+ // transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom}) rotate(${view.rotation ?? 0}deg)`
213
+
214
+ // Programmatic rotation
215
+ rotateTo(45, { animate: true })
216
+ rotateBy(90, { animate: true })
217
+ ```
218
+
219
+ ## Gesture Toggles
220
+
221
+ Enable or disable individual gesture types:
222
+
223
+ ```tsx
224
+ // Only zoom, no pan or rotate
225
+ useZoomPinch({ containerRef, gestures: { pan: false, zoom: true, rotate: false } })
226
+
227
+ // Only rotate (e.g. image rotation tool)
228
+ useZoomPinch({ containerRef, gestures: { pan: false, zoom: false, rotate: true } })
229
+
230
+ // All gestures (pan + zoom + rotate)
231
+ useZoomPinch({ containerRef, gestures: { rotate: true } })
232
+ ```
233
+
234
+ Imperative methods (`setView`, `panTo`, `rotateTo`, etc.) always work regardless of gesture toggles.
235
+
236
+ ## Double-Tap Zoom
237
+
238
+ Double-tap (or double-click) zooms to the tap point. Enabled by default in `toggle` mode.
239
+
240
+ ```tsx
241
+ // Default: toggle between 1x and 2x zoom
242
+ useZoomPinch({ containerRef })
243
+
244
+ // Customize behavior
245
+ useZoomPinch({
246
+ containerRef,
247
+ doubleTap: { mode: "zoomIn", step: 3 }, // always zoom in by 3x
248
+ })
249
+
250
+ // Disable double-tap
251
+ useZoomPinch({ containerRef, doubleTap: false })
252
+ ```
253
+
254
+ ### Modes
255
+
256
+ - `"toggle"` (default) — zoom in if at 1x, reset if zoomed in
257
+ - `"zoomIn"` — always zoom in by `step`
258
+ - `"reset"` — always reset to `{ x: 0, y: 0, zoom: 1 }`
259
+
260
+ ## Inertia
261
+
262
+ Pan with momentum after releasing the pointer. Enabled by default.
263
+
264
+ ```tsx
265
+ // Default: friction 0.92
266
+ useZoomPinch({ containerRef })
267
+
268
+ // Custom friction (lower = more friction, faster stop)
269
+ useZoomPinch({ containerRef, inertia: { friction: 0.85 } })
270
+
271
+ // Disable inertia
272
+ useZoomPinch({ containerRef, inertia: false })
273
+ ```
274
+
275
+ ## Event Filtering
276
+
277
+ Exclude interactive elements (buttons, inputs) from gesture handling:
278
+
279
+ ```tsx
280
+ useZoomPinch({
281
+ containerRef,
282
+ shouldHandleEvent: (e) => !(e.target as HTMLElement).closest(".no-pan"),
283
+ })
284
+ ```
285
+
286
+ ## Granular Events
287
+
288
+ ```tsx
289
+ useZoomPinch({
290
+ containerRef,
291
+ onPanStart: (view) => console.log("Pan started", view),
292
+ onPanEnd: (view) => console.log("Pan ended", view),
293
+ onZoomStart: (view) => console.log("Zoom started", view),
294
+ onZoomEnd: (view) => console.log("Zoom ended", view),
295
+ onPinchStart: (view) => console.log("Pinch started", view),
296
+ onPinchEnd: (view) => console.log("Pinch ended", view),
297
+ onRotateStart: (view) => console.log("Rotate started", view),
298
+ onRotateEnd: (view) => console.log("Rotate ended", view),
299
+ })
300
+ ```
301
+
302
+ ## Bounds
303
+
304
+ Constrain panning within rectangular limits:
305
+
306
+ ```tsx
307
+ useZoomPinch({
308
+ containerRef,
309
+ bounds: { minX: -500, maxX: 500, minY: -300, maxY: 300 },
310
+ })
311
+ ```
312
+
313
+ ## Keyboard Navigation
314
+
315
+ Enable keyboard controls with arrow keys, zoom (+/-), reset (0), and rotation ([ / ]):
316
+
317
+ ```tsx
318
+ // Enable with defaults (panStep: 50, zoomStep: 1.5, rotateStep: 15)
319
+ useZoomPinch({ containerRef, keyboard: true })
320
+
321
+ // Custom steps
322
+ useZoomPinch({
323
+ containerRef,
324
+ keyboard: { enabled: true, panStep: 100, zoomStep: 2 },
325
+ })
326
+ ```
327
+
328
+ ## Coordinate Conversion
329
+
330
+ Convert between screen and content coordinates:
331
+
332
+ ```tsx
333
+ const { screenToContent, contentToScreen } = useZoomPinch({ containerRef })
334
+
335
+ // Map a click to content space
336
+ const handleClick = (e: MouseEvent) => {
337
+ const pos = screenToContent(e.clientX, e.clientY)
338
+ console.log("Clicked at content position:", pos)
339
+ }
340
+
341
+ // Position a tooltip over content
342
+ const screenPos = contentToScreen(itemX, itemY)
343
+ ```
344
+
345
+ ## Zoom Snap Levels
346
+
347
+ Snap zoom to predefined levels on gesture end:
348
+
349
+ ```tsx
350
+ useZoomPinch({
351
+ containerRef,
352
+ zoomSnapLevels: [0.5, 1, 2, 4],
353
+ })
354
+ ```
355
+
356
+ ## Snap to Grid
357
+
358
+ Snap pan position to a grid:
359
+
360
+ ```tsx
361
+ // Snap on gesture end (with animation)
362
+ useZoomPinch({ containerRef, snapToGrid: { size: 50 } })
363
+
364
+ // Snap continuously during gestures
365
+ useZoomPinch({ containerRef, snapToGrid: { size: 50, mode: "always" } })
366
+ ```
367
+
368
+ ## Fit to Content
369
+
370
+ Auto-fit content to container on mount and resize:
371
+
372
+ ```tsx
373
+ const { fitToContent } = useZoomPinch({
374
+ containerRef,
375
+ contentRect: { width: 1920, height: 1080 }, // auto-fits on mount + resize
376
+ })
377
+
378
+ // Manual fit with padding
379
+ fitToContent({ animate: true, padding: 20 })
380
+ ```
381
+
382
+ ## Pan Button
383
+
384
+ Configure which mouse button triggers panning:
385
+
386
+ ```tsx
387
+ // Use middle mouse button for pan (frees left click for selection)
388
+ useZoomPinch({ containerRef, panButton: 1 })
389
+ ```
390
+
92
391
  ## API
93
392
 
94
393
  ### `useZoomPinch(options)`
95
394
 
96
395
  #### Options
97
396
 
98
- | Option | Type | Default | Description |
99
- | ------------------- | -------------------------------- | ------------------------- | ----------------------------------- |
100
- | `containerRef` | `RefObject<HTMLElement \| null>` | _required_ | Ref to the container element |
101
- | `minScale` | `number` | `0.1` | Minimum zoom level |
102
- | `maxScale` | `number` | `50` | Maximum zoom level |
103
- | `panSpeed` | `number` | `1` | Pan speed multiplier (mouse wheel) |
104
- | `zoomSpeed` | `number` | `1` | Zoom speed multiplier (mouse wheel) |
105
- | `initialViewState` | `ViewState` | `{ x: 0, y: 0, zoom: 1 }` | Initial view for uncontrolled mode |
106
- | `viewState` | `ViewState` | — | Controlled view state |
107
- | `onViewStateChange` | `(view: ViewState) => void` | — | Callback on view change |
108
- | `enabled` | `boolean` | `true` | Enable/disable gesture handling |
397
+ | Option | Type | Default | Description |
398
+ | ------------------- | -------------------------------- | ------------------------------------------ | ------------------------------------------------ |
399
+ | `containerRef` | `RefObject<HTMLElement \| null>` | _required_ | Ref to the container element |
400
+ | `minScale` | `number` | `0.1` | Minimum zoom level |
401
+ | `maxScale` | `number` | `50` | Maximum zoom level |
402
+ | `panSpeed` | `number` | `1` | Pan speed multiplier (mouse wheel) |
403
+ | `zoomSpeed` | `number` | `1` | Zoom speed multiplier (mouse wheel) |
404
+ | `initialViewState` | `ViewState` | `{ x: 0, y: 0, zoom: 1 }` | Initial view for uncontrolled mode |
405
+ | `viewState` | `ViewState` | — | Controlled view state |
406
+ | `onViewStateChange` | `(view: ViewState) => void` | — | Callback on view change |
407
+ | `enabled` | `boolean` | `true` | Enable/disable gesture handling |
408
+ | `gestures` | `GesturesOptions` | `{ pan: true, zoom: true, rotate: false }` | Toggle individual gesture types |
409
+ | `panButton` | `0 \| 1 \| 2` | `0` | Mouse button for pan (0=left, 1=middle, 2=right) |
410
+ | `bounds` | `BoundsOptions` | — | Constrain panning within bounds |
411
+ | `keyboard` | `KeyboardOptions \| boolean` | `false` | Keyboard navigation (pass `true` for defaults) |
412
+ | `zoomSnapLevels` | `number[]` | — | Snap zoom to nearest level on gesture end |
413
+ | `snapToGrid` | `SnapToGridOptions \| false` | `false` | Snap position to grid |
414
+ | `contentRect` | `{ width; height }` | — | Content size for `fitToContent` + auto-fit |
415
+ | `rotation` | `RotationOptions` | — | Min/max angle and rotation snap levels |
416
+ | `wheelMode` | `"pan" \| "zoom"` | `"pan"` | Default wheel behavior |
417
+ | `cursor` | `CursorOptions \| false` | enabled | Auto cursor (grab/grabbing). `false` to disable |
418
+ | `axis` | `"x" \| "y"` | — | Restrict gestures to a single axis |
419
+ | `activationKeys` | `ActivationKeyOptions` | — | Require key held for specific gestures |
420
+ | `shouldHandleEvent` | `(event) => boolean` | — | Filter which events are handled |
421
+ | `doubleTap` | `DoubleTapOptions \| false` | `{ mode: "toggle", step: 2 }` | Double-tap zoom config, or `false` to disable |
422
+ | `inertia` | `InertiaOptions \| false` | `{ friction: 0.92 }` | Pan inertia config, or `false` to disable |
423
+ | `onPanStart` | `(view: ViewState) => void` | — | Fired when drag starts |
424
+ | `onPanEnd` | `(view: ViewState) => void` | — | Fired when drag ends |
425
+ | `onZoomStart` | `(view: ViewState) => void` | — | Fired when wheel zoom starts |
426
+ | `onZoomEnd` | `(view: ViewState) => void` | — | Fired when wheel zoom ends (150ms debounce) |
427
+ | `onPinchStart` | `(view: ViewState) => void` | — | Fired when pinch starts |
428
+ | `onPinchEnd` | `(view: ViewState) => void` | — | Fired when pinch ends |
429
+ | `onRotateStart` | `(view: ViewState) => void` | — | Fired when rotation starts |
430
+ | `onRotateEnd` | `(view: ViewState) => void` | — | Fired when rotation ends |
431
+ | `onTransformEnd` | `(view: ViewState) => void` | — | Fired after any gesture ends |
109
432
 
110
433
  #### Returns
111
434
 
112
- | Property | Type | Description |
113
- | ------------ | --------------------------- | ------------------------------------ |
114
- | `view` | `ViewState` | Current view state |
115
- | `setView` | `(view: ViewState) => void` | Imperatively set the view |
116
- | `centerZoom` | `(zoom: number) => void` | Zoom to level, centered in container |
117
- | `resetView` | `() => void` | Reset to `{ x: 0, y: 0, zoom: 1 }` |
435
+ | Property | Type | Description |
436
+ | ----------------- | ---------------------------------- | ----------------------------------------------------- |
437
+ | `view` | `ViewState` | Current view state |
438
+ | `isAnimating` | `boolean` | Whether an animation is running |
439
+ | `setView` | `(view, options?) => void` | Imperatively set the view |
440
+ | `centerZoom` | `(zoom, options?) => void` | Zoom to level, centered in container |
441
+ | `resetView` | `(options?) => void` | Reset to `initialViewState` (or `{0,0,1,0}` if unset) |
442
+ | `zoomIn` | `(step?, options?) => void` | Zoom in by step (default 1.5x) |
443
+ | `zoomOut` | `(step?, options?) => void` | Zoom out by step (default 1.5x) |
444
+ | `zoomToElement` | `(el, scale?, options?) => void` | Zoom and center on an element |
445
+ | `panTo` | `(x, y, options?) => void` | Center viewport on content-space point |
446
+ | `panBy` | `(dx, dy, options?) => void` | Shift viewport by relative offset (screen px) |
447
+ | `zoomTo` | `(zoom, point?, options?) => void` | Zoom to level, optionally anchored |
448
+ | `fitToRect` | `(rect, options?) => void` | Fit a content-space rectangle into view |
449
+ | `rotateTo` | `(angle, options?) => void` | Set rotation to absolute angle (degrees) |
450
+ | `rotateBy` | `(delta, options?) => void` | Rotate by relative delta (degrees) |
451
+ | `screenToContent` | `(screenX, screenY) => { x, y }` | Convert screen to content coordinates |
452
+ | `contentToScreen` | `(contentX, contentY) => { x, y }` | Convert content to screen coordinates |
453
+ | `fitToContent` | `(options?) => void` | Fit content to container (needs `contentRect`) |
454
+ | `snapZoom` | `(options?) => void` | Snap zoom to nearest `zoomSnapLevels` |
118
455
 
119
- ### `ViewState`
456
+ ### Types
120
457
 
121
458
  ```ts
122
459
  interface ViewState {
123
460
  x: number // horizontal offset in pixels
124
461
  y: number // vertical offset in pixels
125
462
  zoom: number // scale factor (1 = 100%)
463
+ rotation?: number // angle in degrees (default 0)
464
+ }
465
+
466
+ interface GesturesOptions {
467
+ pan?: boolean // default true
468
+ zoom?: boolean // default true
469
+ rotate?: boolean // default false
470
+ }
471
+
472
+ interface AnimationOptions {
473
+ animate?: boolean // default false
474
+ duration?: number // default 300ms
475
+ easing?: EasingFunction // default easeOut
476
+ skipConstraints?: boolean // default false (setView only)
126
477
  }
478
+
479
+ type EasingFunction = (t: number) => number
480
+
481
+ interface DoubleTapOptions {
482
+ enabled?: boolean // default true
483
+ mode?: "zoomIn" | "reset" | "toggle" // default "toggle"
484
+ step?: number // default 2
485
+ }
486
+
487
+ interface InertiaOptions {
488
+ enabled?: boolean // default true
489
+ friction?: number // 0–1, default 0.92
490
+ }
491
+
492
+ interface BoundsOptions {
493
+ minX?: number
494
+ maxX?: number
495
+ minY?: number
496
+ maxY?: number
497
+ mode?: "clamp" | "bounce" // default "clamp"
498
+ }
499
+
500
+ interface KeyboardOptions {
501
+ enabled?: boolean // default false
502
+ panStep?: number // default 50
503
+ zoomStep?: number // default 1.5
504
+ rotateStep?: number // default 15
505
+ }
506
+
507
+ interface SnapToGridOptions {
508
+ size: number
509
+ mode?: "end" | "always" // default "end"
510
+ }
511
+
512
+ type ZoomSnapLevel = number
513
+
514
+ interface RotationOptions {
515
+ minAngle?: number
516
+ maxAngle?: number
517
+ snapLevels?: number[] // e.g. [0, 90, 180, 270]
518
+ }
519
+
520
+ interface CursorOptions {
521
+ enabled?: boolean // default true
522
+ idle?: string // default "grab"
523
+ dragging?: string // default "grabbing"
524
+ zooming?: string // default "zoom-in"
525
+ }
526
+
527
+ interface ActivationKeyOptions {
528
+ pan?: string // e.g. "Shift"
529
+ zoom?: string // e.g. "Alt"
530
+ rotate?: string // e.g. "Control"
531
+ }
532
+ ```
533
+
534
+ ## Container Setup
535
+
536
+ The container element **must** have `touchAction: "none"` to prevent the browser from intercepting touch gestures (scroll, pinch-zoom) before the hook can handle them:
537
+
538
+ ```tsx
539
+ <div ref={containerRef} style={{ touchAction: "none", overflow: "hidden" }}>
127
540
  ```
128
541
 
542
+ Without `touchAction: "none"`, pinch-to-zoom and touch drag will trigger native browser behavior instead of your custom handling. The `overflow: "hidden"` prevents content from leaking outside the viewport.
543
+
544
+ ## Browser Compatibility
545
+
546
+ The hook uses standard Web APIs available in all modern browsers:
547
+
548
+ | Feature | Chrome | Firefox | Safari | Edge | iOS Safari | Android Chrome |
549
+ | --------------------- | ------ | ------- | ------ | ---- | ---------- | -------------- |
550
+ | Pointer Events | 55+ | 59+ | 13+ | 12+ | 13+ | 55+ |
551
+ | Wheel Events | 31+ | 17+ | 7+ | 12+ | 7+ | 31+ |
552
+ | Touch Events | 22+ | 52+ | 10+ | 12+ | 10+ | 22+ |
553
+ | ResizeObserver | 64+ | 69+ | 13.1+ | 79+ | 13.4+ | 64+ |
554
+ | requestAnimationFrame | 10+ | 23+ | 6+ | 12+ | 6+ | 10+ |
555
+
556
+ **Minimum:** Chrome/Edge 64+, Firefox 69+, Safari 13.1+, iOS 13.4+.
557
+
558
+ > Safari Gesture Events (`GestureEvent`) are used when available for native trackpad pinch-zoom on macOS.
559
+
560
+ ## Comparison
561
+
562
+ | Feature | useZoomPinch | react-zoom-pan-pinch | @use-gesture/react | motion/react |
563
+ | --------------------- | ------------ | -------------------- | ------------------ | ------------------- |
564
+ | Size (min+gzip) | **~5.8 KB** | ~13.2 KB | ~8.9 KB | ~41.6 KB |
565
+ | Approach | Hook | Components + hook | Gesture primitives | Animation + gesture |
566
+ | Controlled mode | ✅ Native | ❌ | ❌ | ❌ |
567
+ | DOM wrappers | ✅ None | +2 divs | ✅ None | +1 div |
568
+ | Bounds / constraints | ✅ | ✅ | 🔧 Manual | 🔧 dragConstraints |
569
+ | Keyboard navigation | ✅ | ❌ | ❌ | ❌ |
570
+ | Coordinate conversion | ✅ | ❌ | ❌ | ❌ |
571
+ | Snap to grid | ✅ | ❌ | ❌ | ❌ |
572
+ | Zoom snap levels | ✅ | ❌ | ❌ | ❌ |
573
+ | Rotation gestures | ✅ | ❌ | 🔧 Manual | ❌ |
574
+ | Ready-to-use zoom/pan | ✅ | ✅ | 🔧 Manual | 🔧 Manual |
575
+
129
576
  ## License
130
577
 
131
578
  MIT