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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pan-zoom.jsonPrompt
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 astranslate3d(x, y, 0) scale(s)with a top-left origin. That is whyscrollIntoViewis 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;maxScalestill 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
disabledboth go inert, but througharia-disabledand handler guards, never the nativedisabledattribute: zooming in is how you reach the limit, and a control that disables itself under your focus dumps you on<body>.
Audio Transcript
A timestamped, speaker-labelled transcript that follows the playhead, hands scrolling back the moment the reader takes over, and seeks to the exact start of any line you click or search for.
Animated Text
Character-level text animation — typewriter, scramble and flowing gradient as one component's variants.