# PanZoom

**When to use:** A zoomable viewport onto one image, SVG or any other content, inline on the page: zoom with buttons, a pinch, Ctrl + wheel or a double tap, then drag to look around. For a set of images that opens full screen, use `Lightbox`, which puts a PanZoom on every image.

**Keywords:** magnify, enlarge, zoomable, photo, picture, map, closeup, inspect

**Import:** `import { PanZoom } from '@ahrowe/ui'`  
**Types:** `import type { PanZoomProps, PanZoomLabels, PanZoomHandle } from '@ahrowe/ui'`

```tsx
import { PanZoom } from '@ahrowe/ui';

// An image, fitted into a 320px viewport
<PanZoom style={{ height: 320 }}>
  <img src='/floor-plan.png' alt='Ground floor plan' />
</PanZoom>

// Any content: an SVG, a chart, a diagram
<PanZoom style={{ height: 320 }}>
  <svg viewBox='0 0 400 200' style={{ width: '100%', height: '100%' }}>…</svg>
</PanZoom>

// A plain wheel zooms instead of scrolling the page, and no buttons
<PanZoom style={{ height: 320 }} scrollToZoom showControls={false}>
  <img src='/photo.jpg' alt='Harbour at dawn' />
</PanZoom>
```

**Sizing:** PanZoom is a window onto its content, so give it a height (or put it in a flex or grid cell that has one). A direct `<img>`, `<svg>`, `<video>` or `<canvas>` child is fitted inside it with `max-width`/`max-height: 100%` and centred, so leave the image itself unsized. Without a height of its own, PanZoom takes the height of its content at the available width. Don't pad the root: the content is laid out in its content box but zooms from its padding box.

**Zooming:** the content starts fitted and never zooms out below that. How far it zooms in, unless `maxScale` says otherwise: an image as far as its own pixel size, where one pixel of the image takes one pixel of the page, and at least 2x, so an image already shown at its own size still zooms; any other content 4x. A 6000px photo in a 400px viewport therefore zooms to 15x, while a small one stops at 2x, before it only gets blurrier.

**While an image loads:** there is nothing to zoom. As long as an image directly in PanZoom is still loading, or has failed, the buttons are disabled and a pinch, the wheel, a double tap and the keys do nothing; the thumbnail an [`Image`](Image.md) shows meanwhile counts as loading too, as a zoomed-in thumbnail shows only mush. An image that starts loading a new `src` takes PanZoom back to the fitted view. Content without images is not affected.

| Input | What it does |
|-------|--------------|
| Zoom out, zoom in, reset buttons | Zoom by 1.5x around the middle, or go back to the fitted view |
| Pinch | Zoom around the fingers, and pan with them at the same time |
| Ctrl + wheel, trackpad pinch | Zoom around the pointer. A trackpad pinch arrives as Ctrl + wheel in Chrome, Edge, Firefox and Safari |
| Plain wheel | Scrolls the page, or zooms with `scrollToZoom` |
| Double tap or double click | Zoom to 2x at that point, or back to the fitted view when zoomed |
| Drag | Pan, once the content is larger than the viewport |
| `+` / `-` / `0` | Zoom in, zoom out, reset, while PanZoom has focus |
| Arrow keys | Pan by a tenth of the viewport, while there is something to pan |

**Edges:** the content can't be dragged out of sight. On an axis where it is larger than the viewport its edges stop at the viewport's; on one where it is smaller it stays centred. The edges are the content's own, not the viewport's: a wide photo letterboxed in a tall viewport pans only as far as the photo goes.

**Glide:** a pan released while still moving carries on and slows to a stop, the way a scrolled list does, and stops at an edge. A press catches it. Under `prefers-reduced-motion: reduce` it stops where it is released.

**Touch and scrolling:** at the fitted size a one-finger drag scrolls the page as usual, and a pinch zooms. Zoomed in, or with `scrollToZoom`, PanZoom takes every touch gesture over it.

**Inside a Carousel or a swipeable sheet:** at the fitted size a one-finger drag is left to the parent, so a Carousel still swipes. Zoomed in, or with a second finger down, PanZoom stops the pointer events at its root, so a drag pans the image instead of also changing the slide. A swipe the parent has already started stays the parent's: a second finger landing after the first has moved does not start a pinch. `Lightbox` relies on this.

**Controls of your own:** hide the built-in buttons and zoom through a `ref` instead. Its `PanZoomHandle` has `zoomIn()`, `zoomOut()` and `reset()`, which do what the buttons do, and `toggle()`, which does what a double tap in the middle does. It does nothing while `disabled`. `onScaleChange` reports the scale (`1` is the fitted size) whenever it changes, for a button that says whether it zooms in or out:

```tsx
const panZoom = useRef<PanZoomHandle>(null);
const [scale, setScale] = useState(1);

<Button onClick={() => panZoom.current?.toggle()}>{scale > 1 ? 'Fit' : 'Zoom'}</Button>
<PanZoom ref={panZoom} onScaleChange={setScale} showControls={false} style={{ height: 320 }}>
  <img src='/floor-plan.png' alt='Ground floor plan' />
</PanZoom>
```

**Accessibility:** the root is focusable (`tabIndex` 0) so the keys above reach it; pass an `aria-label` when the content doesn't describe itself. The buttons stay focusable at the end of their range and are marked `aria-disabled` there instead. The animated steps (buttons, keys, double tap) jump instead under `prefers-reduced-motion: reduce`; a pinch or a drag follows the fingers either way.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | What to zoom |
| `maxScale` | `number` | Highest zoom, as a multiple of the fitted size. Default: an image's own pixel size and at least 2x, other content 4x |
| `scrollToZoom` | `boolean` | Zoom on a plain mouse wheel or two-finger scroll, instead of scrolling the page (default `false`) |
| `showControls` | `boolean` | Show the zoom out, zoom in and reset buttons in the bottom right corner (default `true`) |
| `disabled` | `boolean` | Ignore input, hide the buttons and go back to the fitted view (default `false`) |
| `onScaleChange` | `(scale: number) => void` | Called with the scale whenever it changes, `1` being the fitted size |
| `onTap` | `(event: PointerEvent) => void` | Called on a single tap or click, 300ms later, once it can no longer be half of a double tap. Gets the `pointerup`, whose `pointerType` tells a finger from a mouse. Lightbox uses it to hide its controls |
| `ref` | `Ref<PanZoomHandle>` | `zoomIn()`, `zoomOut()`, `reset()` and `toggle()`, for controls of your own |

**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ PanZoom: { maxScale: 8 } }}`. See [ConfigProvider.md](ConfigProvider.md).

**Slots:** `root` `content` `controls` `zoomOutButton` `zoomInButton` `resetButton`

**Labels:** `zoomIn` `zoomOut` `reset` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `PanZoomLabels`.
