Mobile

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.

Preview in your theme

Loading preview…

"use client"

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

/**
 * Fit — the photo as it already sits on the screen. Every scale in this file is a
 * multiple of it, which is the only unit a phone user can reason about: "twice as
 * big as it looked", never "42% of the file's pixels".
 */
const FIT = 1
const DEFAULT_RATIO = 4 / 3
const DEFAULT_MAX_SCALE = 4

Installation

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

Prompt

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 single ratio prop 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 passing max. 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-action is none the 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 calls onEdgeEscape("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 preventDefault on 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 are aria-disabled with a guard in the handler, because a button that disables itself under the finger throws focus onto <body>.

On This Page