Pinch Zoom
A photo you pinch, drag and double-tap inside its own frame: fit is 100%, every edge is a rubber band, and the sideways swipe past an edge is reported to the host instead of performed.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pinch-zoom.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PinchZoom" component — the phone gesture for
looking at a photo more closely, inside a frame the photo can never be lost outside
of. React + lucide-react only: no gesture library, no animation library, no
transform-matrix library.
Contract
- "use client". forwardRef<HTMLDivElement, PinchZoomProps> extending
React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root, which
IS the gesture surface (one element: chrome, focus, pointer capture and clipping).
- PinchZoomView = { scale; x; y }: a point p of the fitted box is painted at
p * scale + (x, y) inside the frame. Every operation below is one of exactly two
things done to that triple — translate it, or rescale it about a fixed frame point.
- Props:
- children — the photo. An <img> child is sized to the fitted box automatically.
- ratio = 4/3 — content aspect ratio, width / height. This is the ONLY thing the
component needs to know about the content; it never reads naturalWidth, so a
photo that has not loaded yet still lays out correctly.
- height = 300 — frame height; the width always fills the parent.
- maxScale = 4 — the ceiling, as a multiple of fit. Anything below 1 is lifted to 1.
- doubleTapScale = 2 — where one double tap lands, clamped into 1..maxScale.
- scale / defaultScale = 1 / onScaleChange — controlled and uncontrolled zoom.
- onEdgeEscape?: (edge: "previous" | "next") => void — see Behavior.
- variant: "card" | "viewer" | "bare" = "card".
- controls: "always" | "zoomed" | "none" = "always".
- locked = false, safeArea = true, label = "Zoomable photo".
- Mirror data-variant, data-zoomed and data-locked onto the root so consumers can
skin by state.
- FIT IS 1. Scale is a multiple of how the photo already looks on this screen, not a
share of the file's pixels. That is the only unit the person holding the phone can
reason about ("twice as big as it looked"), and it means the same component works
for an 8000px scan and a 300px thumbnail with no per-photo configuration.
- Sanitise every numeric prop through one helper that rejects non-finite and <= 0
values. A single NaN would travel into transform: scale(NaN) — photo gone, no error
anywhere.
Behavior
- Geometry. Measure the frame with clientWidth/clientHeight, then again through a
ResizeObserver. The fitted box is the largest box of `ratio` that fits inside the
frame ("contain"), and that box is scale 1. Write its width/height straight to the
DOM rather than into React state — it is the same kind of value as the transform
and changes on the same events, so a resize costs no render. A 0x0 frame (hidden
tab, not laid out yet) makes the surface inert instead of producing NaN; the
observer calls again with real numbers. The first measurement centres the starting
scale; every later one keeps the crop the user chose and only re-clamps it.
- Limits. An axis larger than the frame pans inside its own edges, so no gutter can
ever open up beside the photo; an axis smaller than the frame is pinned to its
centred position — that is what keeps a 3.4:1 panorama out of its own letterbox.
- Rubber bands, everywhere, and they are the point. While a finger is down, every
limit is soft: overshoot is painted as max * (1 - exp(-d / max)), which is 1:1 with
the finger for the first pixels, ever heavier after that, and mathematically capped.
88px past a border; 0.35 below fit and 0.35 * maxScale above the ceiling. On release
the view is hard-clamped and eased home over 260ms. Resistance is the whole reason
an edge feels like an edge instead of like a wall the photo hit.
- Pointer Events only — never a mouse path beside a touch path. On pointerdown take
setPointerCapture ON THE FRAME (the element the gesture started on): a finger that
slides past the photo or off the screen edge has to keep delivering moves or the
photo freezes mid-pan. Nothing calls preventDefault anywhere; touch-action declares
the intent instead, so no listener ever has to fight a passive registration.
- touch-action is the arbitration, and it is state-dependent:
- zoomed -> "none": every touch is ours.
- fitted -> "pan-y": vertical panning goes back to the page, so a photo in a feed
never traps the scroll, while horizontal and multi-finger stay ours — which is
what stops the browser stealing the pinch and what makes double-tap ours rather
than the browser's own zoom.
- locked -> untouched.
- Pinch. The second pointer down begins a pinch: record the two points, their spread
(floored at 1px — a zero spread would divide the whole view by nothing on the next
frame) and their midpoint, plus the view at that instant. Every later frame is
computed FROM THAT START, never accumulated, so no drift builds up. The midpoint is
free to travel: on a phone, pinching and dragging are one gesture, so the content
point that was under the starting midpoint follows the current midpoint. A third
finger joining is ignored (the first two keep the pinch); lifting to one finger
re-baselines into a pan so the photo does not jump.
- Pan. A one-finger drag is claimed after 8px of travel, and only when there is
something to do with it: while zoomed, always; while fitted, only when the drag is
more horizontal than vertical AND onEdgeEscape was provided. Anything else is
dropped for good, mid-gesture, so a flick through a feed is never half-stolen.
- Double tap. Two presses that never travelled, within 320ms and 32px of each other:
jump to doubleTapScale anchored under the finger, or back to fit if already zoomed.
The stored tap is read AND cleared in the same handler, so a triple tap is a fresh
first tap rather than a second double.
- Edge escape. A ONE-FINGER drag past a side by more than 56px, released: the
component calls onEdgeEscape("previous" | "next") and then springs home. A pinch
never pages — two fingers travel a long way sideways by design, and paging out of a
gesture the user is still shaping is a surprise. It reports, it never
pages — only the host knows whether there is a neighbouring photo. This is the
mobile answer to a problem that has no good one: once touch-action is "none" the
browser cannot hand a gesture to a parent pager mid-flight, so the intent is handed
over as data instead. Passing the callback is also what opts the fitted state into
claiming sideways drags at all.
- Controlled zoom. `scale` makes the owner hold the number: a gesture still paints
freely and reports through onScaleChange, and an effect pulls the view back to
whatever the owner is really holding — so refusing to echo springs the photo back
instead of silently desyncing. A commit counter re-runs that effect after a refused
commit, when the prop itself never changed.
- Painting. The view lives in a ref, never in state: a 60fps pinch would re-render
the whole subtree for no visual gain. One rAF coalesces many pointer events into one
transform write; the readout's textContent is written by the same painter, while
React only re-renders it when the settled scale changes, so the two never fight.
- Keyboard, equal to every gesture: + / = and - / _ zoom about the centre by 1.5x,
0 fits, Enter and Space toggle the double-tap step (about the centre, since there is
no finger to anchor to), arrows pan 48px and Shift + arrow most of a screen. Only
handled keys call preventDefault, so Tab still leaves.
- Cleanup: the rAF is cancelled on unmount and before every settle (a stale frame must
not overwrite the spring-back), the announcement timer is cleared, the pointer map
and both gesture records are dropped, the ResizeObserver is disconnected, the
reduced-motion media query is unsubscribed through useSyncExternalStore, and pointer
capture is released on the frame for pointerup and pointercancel alike.
- Edge cases: ratio <= 0 or NaN falls back to 4/3; maxScale <= 1 means the pill is
aria-disabled and double tap does nothing; a pointercancel (a scroll starting, the
notification shade, an incoming call) writes off EVERY finger at once and springs
home without reporting an escape — rebaselining onto a finger that may never report
again would leave the photo parked past its own edge; a claimed drag sets a flag
that swallows the click the browser synthesises next; locked ignores gestures and
keys and swaps the description for "Zooming is locked for this photo."
Rendering & styling
- Semantic tokens only, monochrome first. card: rounded-2xl border on bg-muted, pill
in bg-card/90. viewer: the highest-priority surface, so it INVERTS —
bg-foreground text-background, with the pill in bg-background/10 and
border-background/15 rather than taking a colour. bare: rounded-lg bg-muted, no
border. Radius ladder 16/12/8; the pill is fully round.
- Type is small and tight: the readout is 12px, semibold, tabular-nums, in a 56px slot
so 100% and 400% do not shuffle the buttons.
- Touch: every pill button is size-11 (44px) with a 16px glyph inside, padding doing
the work. Nothing depends on hover. controls="zoomed" hides the pill with opacity —
not display or visibility — plus pointer-events-none, so it stays in the tab order
and focus-within brings it back before it can be used.
- Safe area: the pill pads with max(0.75rem, safe-area-inset-*) on bottom / left /
right, read as var(--safe-area-inset-*) first and env() second so a device-frame
preview or a test can simulate a home indicator on hardware that reports 0.
- Motion: the only animation is the 260ms cubic-bezier(0.22, 1, 0.36, 1) settle, and
under prefers-reduced-motion (subscribed, not read once) it becomes an instant jump.
Nothing about zooming, panning or paging depends on it.
- Accessibility: the frame is role="group" with aria-label, tabIndex 0, a focus-visible
ring drawn inset (the frame clips), and an aria-describedby pointing at an sr-only
line that names every key. A polite role="status" region announces settled zoom
levels only — never a pinch frame, which would be a screen-reader storm — and clears
itself after 1.4s so the same percentage can be announced again later. Controls at a
limit are aria-disabled with a guard in the handler, never natively disabled: zooming
in with the button is exactly how you reach the ceiling, and a control that disables
itself under the finger drops focus onto <body>. Images inside get
pointer-events-none and select-none — a native image drag would hijack the gesture,
and the frame is the element holding capture anyway.
Customization levers
- The physics are five numbers: PAN_BAND_PX (88 — how far an edge gives), SCALE_BAND
(0.35 — how far a pinch stretches past a limit), SETTLE_MS (260 — spring-back), and
TAP_SLOP / DOUBLE_TAP_MS (8px / 320ms — how forgiving a tap is). Lower the band to
near 0 for a rigid, kiosk-like feel; raise SETTLE_MS for a heavier photo.
- maxScale and doubleTapScale are the reading levers: 4x and 2x for photos, 6x and 3x
for documents and fine print, maxScale=1 to turn the whole feature off while keeping
the frame's layout and its accessible description.
- variant is the density lever, not a palette: card for a feed, viewer for a
full-screen presentation (pass height="100dvh" and className="rounded-none"), bare
for a thumbnail whose chrome should stay out of the way until it is needed.
- controls="zoomed" for a clean resting state, "none" when you render your own zoom UI
— the keyboard path survives either way, so it never removes the last route in.
- ratio is the whole content contract. Feed it from your own image metadata; for a
gallery, pass each photo's ratio along with its src and the frame re-fits with no
other change.
- Wire it up as it should be: the component does not position itself, does not page,
and does not decide what a swipe means. Give it a height, hand onEdgeEscape to your
pager, and control `scale` when you want zoom reset on page change.
- Skin by state through data-variant / data-zoomed / data-locked on the root, and
build a caption or a counter as a sibling — anything absolutely positioned inside
the frame would ride the transform.Concepts
- Fit is 1 — scale is a multiple of how the photo already looks on this screen, not a share of the file's pixels. It is the only unit a person holding a phone can reason about, and it makes the component indifferent to the content: an 8000px scan and a 300px thumbnail both start at 100%, and “double-tap goes to 2x” means the same thing for both. The component never reads
naturalWidth; the singleratioprop is the entire content contract, so a photo that has not loaded yet still lays out correctly. - Rubber band, then spring — every limit is soft while a finger is down and hard the moment it lifts. Overshoot is painted as
max × (1 − e^(−d/max)): 1:1 with the finger at first, ever heavier, and mathematically incapable of passingmax. Pinching below fit stretches by up to 0.35 and springs back; dragging past a border gives up to 88px and eases home in 260ms. Without the band an edge feels like a bug; with it, the boundary is something you can feel before you hit it. - The travelling pinch midpoint — on a phone, pinching and dragging are not two gestures taken in turns. The content point under the starting midpoint is kept under the current midpoint, so the hand can rotate, slide and spread in one motion and the photo tracks all three. Every frame is recomputed from the view captured at pinch-start rather than accumulated onto the last one, which is what stops a long gesture from drifting.
- Edge escape: reporting a swipe instead of performing it — once
touch-actionisnonethe browser can no longer hand a mid-flight gesture to a parent pager, and a zoomed photo cannot politely give one back either. So the intent is handed over as data: drag one finger past a side by 56px, release, and the component callsonEdgeEscape("previous" | "next")and springs home. Only the host knows whether there is a neighbouring photo, so the host pages — and the same hook covers the fitted state, where passing the callback is what opts sideways drags into being claimed at all. - touch-action is the negotiation — zoomed, the surface owns every touch; fitted, it hands vertical panning back so a photo in a feed never traps the page scroll, while keeping horizontal and multi-finger for itself. That single state-dependent declaration is what stops the browser from stealing the pinch and what makes the double tap the component's rather than the browser's built-in zoom — and it means no handler ever has to call
preventDefaulton a listener the browser registered as passive. - Every gesture has a twin that is not a gesture — pinch has
+/−and two buttons, double tap has Enter and the fit button, panning has the arrow keys (Shift for a screen at a time), and the swipe has whatever prev/next control the host already owns. Zoom levels are announced only once they settle, so a pinch is silent while it runs; controls at a limit arearia-disabledwith a guard in the handler, because a button that disables itself under the finger throws focus onto<body>.
Double Tap Heart
A media surface that likes on double tap, bursting a mark where the thumb landed while the first tap is left to the control underneath.
Swipe Pager
Full-width paged views under a horizontal drag — the deck tracks the finger 1:1, resists past the first and last page, commits on flick or distance, and reports position through a dot, bar or counter rail.