Media

Pan Zoom

A pan-and-zoom viewport for content of known size — wheel, pinch and drag anchored under the pointer, a fit / actual-size toolbar, and a full keyboard path.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Maximize2, Scan, ZoomIn, ZoomOut } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The whole view state. A content point `p` is painted at `p * scale + (x, y)`
 * inside the viewport, so every gesture below is one of two operations on this
 * triple: translate it, or rescale it around a fixed viewport point.
 */
export interface PanZoomView {
  scale: number
  /** Viewport-space position of the content layer's top-left corner, in px. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pan-zoom.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PanZoom" viewport component
(lucide-react for the four toolbar icons; no gesture or animation library —
Pointer Events plus one CSS transform do all of it).

Contract
- export interface PanZoomView { scale: number; x: number; y: number } — x and y
  are the viewport-space position of the content layer's top-left corner, in CSS
  px. This triple is the entire view state.
- export type PanZoomWheelBehavior = "zoom" | "ctrl-zoom" | "none".
- export const PanZoom = forwardRef<HTMLDivElement, PanZoomProps>(...) where
  PanZoomProps extends React.HTMLAttributes<HTMLDivElement>. The forwarded ref
  and the remaining props land on the outer positioning wrapper (the toolbar is
  its second child), and the consumer's className is merged there with cn().
- Props, with defaults: children: ReactNode; contentWidth: number and
  contentHeight: number (the intrinsic size at scale 1 — this is the coordinate
  system, not a hint); height = 320 (number | string; the width always fills the
  parent); minScale = 0.25; maxScale = 8; initialView: "fit" | "actual" = "fit";
  wheelBehavior = "zoom"; doubleClickStep = 2; zoomStep = 1.4; panStep = 48;
  controls = true; disabled = false; label = "Pan and zoom viewport";
  onViewChange?: (view: PanZoomView) => void, fired after every committed view.
- Every numeric prop is filtered through positive(value, fallback) =
  Number.isFinite(value) && value > 0 ? value : fallback. These numbers are
  computed by callers as often as they are typed, and one NaN would flow through
  the clamp into transform: scale(NaN) — content gone, no error anywhere.
- Two derived booleans gate everything: usable = contentWidth and contentHeight
  are finite and > 0; interactive = usable && !disabled.

The maths — three pure functions serve all four input paths
- Painting: a content point p appears at p * scale + (x, y). The content layer
  is transform: translate3d(x, y, 0) scale(s) with origin-top-left, and the
  viewport is overflow-hidden. There is no scroll container anywhere.
- scaleAbout(view, s2, px, py): keep the content point under viewport point
  (px, py) pinned. From p = (v - t) / s and t2 = v - p * s2 you get
  t2 = v - (v - t) * s2 / s. Wheel, pinch and double-click pass the pointer;
  buttons and keys pass the viewport centre, because they have no pointer.
- scaleBounds(size, content, minScale, maxScale): raw = min(size.w / content.w,
  size.h / content.h); low = min(minScale, raw); high = max(maxScale, low);
  fit = min(raw, high). low is deliberately NOT minScale: a 12000px plan inside
  a 400px box needs 0.03, far under any sane minScale, and a Fit button that
  cannot fit is a lie. high = max(...) also survives a caller passing
  minScale > maxScale without producing an inverted clamp.
- clampView(view, size, content, low, high) — the contain rule: clamp the scale
  first, then per axis, if content * scale <= viewport, centre it
  ((size - painted) / 2); otherwise clamp the offset into
  [size - painted, 0] so an oversized axis pans strictly inside its own edges
  and no empty gutter can ever appear. Every committed view goes through it.
- centeredView(scale, size, content) lays the content out centred at a scale —
  used for the initial view, Fit and reset.

State discipline
- The authoritative view lives in a ref (viewRef), and a state object
  { animate, size, view } only mirrors it for rendering. Two pointer events in
  the same frame must compose, not both read the pre-gesture view out of React
  state. The setState updater returns the previous object unchanged when all
  three numbers and the animate flag match, so a no-op gesture does not re-render.
- Size comes from a ResizeObserver on the viewport, written to a ref that every
  gesture reads synchronously. A responsive parent changes both the fit scale and
  the pan limits, so each observation re-clamps the current view — that is what
  stops a gutter from opening on the right edge after a resize.
- Seed the size once in a layout effect (before paint) from clientWidth /
  clientHeight — not getBoundingClientRect, whose border box is 2px wider than
  what the observer reports, so a first view computed on the other box visibly
  re-seats itself on mount. Use useEffect instead of useLayoutEffect when
  document is undefined so SSR does not warn.
- Changing contentWidth / contentHeight / minScale / maxScale / initialView
  redefines the coordinate system, so that effect resets viewRef to null and the
  view is recomputed from scratch rather than carried over meaninglessly.
- A collapsed or hidden box (0 x 0) has no fit scale: record the size, render
  nothing placed, and wait for the observer to report real numbers.

Behavior — pointer
- Keep a Map of live pointerId -> client point. One pointer arms a drag record
  { id, startX, startY, lastX, lastY, moved: false }; the arrival of a second
  pointer cancels the drag and starts a pinch { distance, midX, midY }; a third
  is ignored.
- Drag slop: the press only becomes a pan once it has travelled 4px, and the
  promotion flag is read and written synchronously on the ref inside the handler
  (a state flag would let a second move in the same frame promote it twice).
  Pointer capture is taken at that moment, NOT on pointerdown — capturing early
  redirects the click away from a marker inside the content and breaks plain
  tap-to-select. Wrap setPointerCapture in try/catch: it can legitimately throw
  when the pointer is already gone, and an exception there would abort the pan.
- Pan by relative deltas (client minus last, then update last), never by an
  absolute mapping, so grabbing off-centre never makes the content jump.
- Once the press has travelled, arm a suppressClick ref; a click-CAPTURE handler
  on the viewport consumes exactly one click (preventDefault + stopPropagation)
  so a pan that ends over a link or a marker does not activate it. Every new
  pointerdown clears the flag, because a drag that ended outside the viewport
  never produced a click to consume.
- Pinch: newScale = scale * (distance / previousDistance), applied with
  scaleAbout at the previous midpoint, then translated by the midpoint delta —
  one gesture both zooms and pans, the way a native map behaves. Store the new
  distance and midpoint each move.
- On pointerup / pointercancel: delete the id, release capture if held, drop the
  pinch when fewer than two pointers remain, and if exactly one pointer is still
  down hand the gesture back to plain panning (re-arm the drag with moved: true)
  instead of freezing until the user lifts and touches down again.
- A press released outside the element never reaches its own pointerup, so its id
  would sit in the map forever and the next single press would be read as the
  second finger of a pinch. A window-level pointerup / pointercancel listener
  forgets any id still in the map (it returns early for ids the element already
  handled, since those events bubble to window too).
- Double-click zooms in by doubleClickStep about the pointer; Alt (or Shift) +
  double-click divides by it, so a pointer-only user is never stuck at the top of
  the range.

Behavior — wheel
- Attach the wheel listener natively with { passive: false } and call
  preventDefault, guarded by event.cancelable (it is not cancelable inside an
  already-running native scroll). React registers onWheel passively, so
  preventDefault inside the prop is ignored and the page scrolls out from under
  the gesture. Route the handler through a latest-ref so the listener is attached
  exactly once instead of being torn down every drag frame.
- Normalise deltaMode first: 1 means lines (use a 16px line box), 2 means pages
  (use the viewport height), 0 is already pixels. Then
  factor = Math.exp(-deltaY * unit / 320). The exponential makes zoom
  scale-free and symmetric: n notches down then n notches up lands on exactly the
  scale you started from, at any zoom level.
- wheelBehavior: "zoom" zooms on every tick (the page never scrolls over the
  viewport); "ctrl-zoom" requires ctrlKey or metaKey and otherwise lets the page
  scroll — this is also the trackpad pinch, which arrives as a wheel event with
  ctrlKey set; "none" disables wheel zooming entirely.

Behavior — keyboard (the viewport is tabIndex={0})
- ArrowLeft / ArrowRight / ArrowUp / ArrowDown pan by panStep. Arrows move the
  VIEWPORT over the content like scrolling: pressing Right reveals what is to the
  right, so the layer translates left (-step). Shift + arrow moves 0.9 of the
  viewport instead — a whole screen with a sliver of overlap.
- "+" or "=" multiplies the scale by zoomStep, "-" or "_" divides by it, both
  about the viewport centre. "0" returns to the configured home view (fit or
  actual, per initialView). "1" jumps to 100% about the centre — about, not
  re-centred, so what you were looking at stays in the middle.
- Bail out when event.defaultPrevented is set, and when the event target is
  inside an input, textarea, select or contenteditable in the content — never eat
  typing or a native control's own arrow keys. Otherwise preventDefault on every
  handled key so the page does not scroll as well.
- Focus reveal: a focusin-capture handler pans a newly focused descendant into
  view with a 24px margin, comparing its bounding rect against the viewport's.
  Translating the layer by d moves the painted rect by exactly d at any scale, so
  this is plain viewport arithmetic — and it is the only way in, because there is
  no scroll container for scrollIntoView to move. An element wider than the
  viewport aligns to the left/top edge rather than being pushed past it
  (Math.max of the two corrections).
- pointerdown focuses the viewport, unless the press landed on an interactive
  element inside the content (closest("a,button,input,select,textarea,
  [contenteditable=true]")), which must keep the focus it is about to receive.

Toolbar and announcements
- controls renders a four-button group as a SIBLING of the viewport, absolutely
  positioned bottom-right: zoom out, a live percentage readout, zoom in, fit,
  actual size. A sibling, not a child, so a press on a button is never read as
  the start of a pan.
- Limit states use aria-disabled plus an early return in the handler, never the
  native disabled attribute: pressing zoom-in is exactly how you reach maxScale,
  and a control that disables itself under the pointer or keyboard drops focus to
  <body> mid-interaction. A scale within 1e-4 of a bound counts as at the bound.
- Discrete actions (buttons and keys) animate — transition-transform 200ms
  ease-out with motion-reduce:transition-none — and announce
  "Zoom {percent}%" into an sr-only role="status" aria-atomic region. Continuous
  ones (drag frames, wheel ticks, pinch) never animate and never announce, or the
  live region becomes a stream of noise. Clear the announcement after ~1.2s so the
  SAME percentage can be announced again later; an unchanged live region stays
  silent.

ARIA contract
- Viewport: role="group", aria-label={label}, tabIndex={0},
  aria-describedby pointing at an sr-only paragraph that states the gestures and
  the whole key map, and aria-disabled when it is not interactive. That paragraph
  has three texts: interactive, locked (disabled), and "size unknown".
- Toolbar: role="group" aria-label="View controls"; each button is
  type="button" with an explicit aria-label ("Zoom out", "Zoom in", "Fit content
  to view", "Actual size, 100 percent") and an aria-hidden icon; the readout is a
  plain tabular-nums span.
- Decorative layers inside the content (grid paper, connector SVG) should be
  aria-hidden by the consumer; interactive markers stay real buttons and keep
  their own semantics.

Degenerate cases — all refusals, none of them throw
- contentWidth or contentHeight is 0, negative, NaN or Infinity: usable is false.
  No transform is applied, the layer just fills the box, the readout shows an em
  dash, every gesture and key returns early, and the description says the size is
  unknown. This is the "the drawing has not measured itself yet" state.
- disabled: the content is laid out at the initial view and stays there; wheel
  events are not intercepted, so the page scrolls normally again.
- Before the first measurement (SSR and the first client paint) there is no view:
  render the layer invisible rather than paint one frame of unscaled, unplaced
  content.
- Content smaller than the viewport on an axis is centred on that axis and cannot
  be panned along it.
- ResizeObserver undefined (jsdom, very old browsers): keep the one-shot layout
  measurement and skip the subscription.

Cleanup — every one of these, or it leaks
- ResizeObserver.disconnect() on unmount and whenever the content size props
  change; removeEventListener for the non-passive wheel listener and for the
  window pointerup / pointercancel pair; clearTimeout on the announcement timer;
  clear the pointer map and null the drag / pinch refs on unmount;
  releasePointerCapture on pointerup and pointercancel.

Rendering & styling
- Semantic tokens only: the viewport is rounded-lg border bg-muted/30
  overflow-hidden with focus-visible:ring-2 ring-ring ring-offset-2
  ring-offset-background; the toolbar is bg-card/90 border shadow-sm
  backdrop-blur-sm; buttons are text-muted-foreground with hover:bg-accent
  hover:text-accent-foreground and aria-disabled:opacity-50
  aria-disabled:cursor-not-allowed. No hardcoded colors anywhere.
- touch-action: none on the viewport while interactive, or the browser scrolls
  the page instead of letting the touch reach the pointer handlers. cursor-grab,
  swapped for cursor-grabbing select-none while a drag is live.
- will-change: transform on the content layer, and translate3d rather than
  translate, so zooming stays on the compositor.
- prefers-reduced-motion only removes the 200ms transition on discrete jumps;
  panning, zooming, the keyboard map and the announcements all still work.

Customization levers
- Feel: WHEEL_DIVISOR (320 px per e-fold — larger is slower), zoomStep 1.4,
  doubleClickStep 2, panStep 48 and the 0.9 page fraction. DRAG_SLOP 4px is the
  drag-vs-click threshold: raise it for touch-heavy content with small targets.
- Range: minScale / maxScale, and initialView "fit" vs "actual". Remember the fit
  scale always overrides minScale downwards; delete that min() only if you would
  rather have a Fit button that cannot fit.
- Toolbar: drop controls and drive the view from your own UI with the same
  handlers, or keep it and re-place it (bottom-2 right-2 -> top-2 left-2), or trim
  it to zoom out / in only. Adding a rotate button means adding an angle to
  PanZoomView and composing rotate() before scale() in the transform.
- Boundary policy: clampView implements "contain". For an infinite canvas, replace
  it with a pass-through (or allow a margin of size * 0.5 on each side) — nothing
  else in the component assumes the content stays inside.
- Wheel policy: "ctrl-zoom" is the polite default inside a long scrolling page;
  "zoom" suits a full-bleed viewer that owns its area.
- Content: anything with a known intrinsic size — an inline SVG, absolutely
  positioned nodes, an <img> with width/height, a canvas. Interactive children
  keep working: onClick and href are the consumer's, the component only swallows
  the click that was really a pan.

Concepts

  • Pointer-anchored zoom — the wheel, the pinch and the double-click all solve t2 = v - (v - t) * s2 / s, which keeps the content point under the cursor exactly where it was; buttons and keys fall back to the viewport centre because they have no cursor to anchor to. Without the anchor, zooming in on a corner of a diagram walks you off it.
  • One transform, no scroll container — the whole view is { scale, x, y } painted as translate3d(x, y, 0) scale(s) with a top-left origin. That is why scrollIntoView is useless here and why focusing a control inside the content has to pan the layer by hand.
  • Fit is a floor override, not a clamp — the lower scale bound is min(minScale, fitScale), so a 12000px plan in a 400px box can still reach the scale that shows all of it; maxScale still caps how far a tiny drawing is blown up.
  • Contain clamp — an axis larger than the viewport pans strictly inside its own edges (no empty gutter can appear), an axis smaller than it is centred and cannot pan at all. Every gesture ends here, so resizing the parent re-clamps instead of stranding the content off-screen.
  • Drag slop, then capture — a press is a click until it has travelled 4px; only past that does it take pointer capture and arm a one-shot click suppression in the capture phase. That is what lets a marker inside the canvas be both a real button and a place you can grab to pan.
  • Refusals stay reachable — an unknown content size and disabled both go inert, but through aria-disabled and handler guards, never the native disabled attribute: zooming in is how you reach the limit, and a control that disables itself under your focus dumps you on <body>.

On This Page