Skip to example

Pinch to zoom

An example of pinch to zoom with Motion for React. The pinch prop zooms the photo around your fingers, drag looks around, and a double tap zooms in on a point. Let go and it springs back inside the frame.

React

Source code

"use client"

import {
  animate,
  cancelFrame,
  frame,
  LazyMotion,
  motion,
  pinchFeatures,
  useMotionValue,
  useMotionValueEvent,
  useTransform,
} from "motion/react"
import type { PanInfo, TapInfo } from "motion/react"
import { useRef, useState } from "react"

const ui = { type: "spring", stiffness: 305, damping: 33 } as const

/**
 * How far the photo can move at a given scale before an edge comes
 * inside the frame.
 */
const limit = (size: number, scale: number) =>
  Math.max(0, (size * (scale - 1)) / 2)

const clampPan = (value: number, size: number, scale: number) =>
  Math.min(limit(size, scale), Math.max(-limit(size, scale), value))

export default function PinchZoom() {
  const viewer = useRef<HTMLDivElement>(null)
  const isDragging = useRef(false)

  /**
   * pinch, drag and animate() all use the photo's scale, x and y. Passing
   * our own motion values to style means we can read and animate them.
   */
  const scale = useMotionValue(1)
  const x = useMotionValue(0)
  const y = useMotionValue(0)

  const hintOpacity = useTransform(scale, [1, 1.15], [1, 0])
  const zoomOpacity = useTransform(scale, [1, 1.15], [0, 1])
  const [zoom, setZoom] = useState("1.0×")
  useMotionValueEvent(scale, "change", (latest) =>
    setZoom(latest.toFixed(1) + "×"),
  )

  /**
   * Keep the photo covering the frame: once the zoom has settled, glide
   * back so no edge shows. A flick carries on a little way first.
   */
  const settleEdges = (velocity = { x: 0, y: 0 }) => {
    const s = scale.get()
    const { offsetWidth: width, offsetHeight: height } = viewer.current!

    animate(x, clampPan(x.get() + velocity.x * 0.2, width, s), {
      ...ui,
      velocity: velocity.x,
    })
    animate(y, clampPan(y.get() + velocity.y * 0.2, height, s), {
      ...ui,
      velocity: velocity.y,
    })
  }

  // Wait for the pinch's own spring back to finish, then fix the edges
  const settling = useRef<VoidFunction>(() => {})

  const afterZoomSettles = (callback: VoidFunction) => {
    cancelFrame(settling.current)
    let last: number | undefined
    const check = (settling.current = () => {
      const latest = scale.get()
      if (latest === last) {
        cancelFrame(check)
        callback()
      }
      last = latest
    })
    frame.postRender(check, true)
  }

  /**
   * tap={{ count: 2 }} only fires onTap after a second, quick tap. It
   * zooms in on the point that was tapped, or back out.
   */
  const onDoubleTap = (_event: PointerEvent, info: TapInfo) => {
    const rect = viewer.current!.getBoundingClientRect()
    const current = scale.get()
    const target = current > 1.05 ? 1 : 2.5

    // The tapped point, from the frame's centre
    let px = info.point.x - window.scrollX - rect.left - rect.width / 2
    let py = info.point.y - window.scrollY - rect.top - rect.height / 2

    // Keyboard presses have no pointer position, so zoom on the centre
    if (Math.abs(px) > rect.width / 2 || Math.abs(py) > rect.height / 2) {
      px = 0
      py = 0
    }

    // Keep the tapped point under the finger as the scale changes
    const ratio = target / current
    animate(scale, target, ui)
    animate(x, clampPan(px - (px - x.get()) * ratio, rect.width, target), ui)
    animate(y, clampPan(py - (py - y.get()) * ratio, rect.height, target), ui)
  }

  return (
    <LazyMotion features={pinchFeatures}>
      <div className="pz-viewer">
        <motion.div
          ref={viewer}
          className="pz-frame"
          aria-label="Pinch or double tap to zoom"
          tap={{ count: 2 }}
          onTap={onDoubleTap}
        >
          <motion.img
            className="pz-photo"
            src="/photos/japan/5.jpg"
            alt="A street in Tokyo at night"
            draggable={false}
            style={{ scale, x, y }}
            /**
             * Past 1x or 4x the pinch resists, and springs back inside
             * them when the fingers lift, around the point between them.
             */
            pinch={{
              scale: { min: 1, max: 4 },
              rotate: false,
              // A new pinch takes over from a settle still waiting to run
              onStart: () => cancelFrame(settling.current),
              // A finger still down carries on as a drag, which settles on release
              onEnd: () => !isDragging.current && afterZoomSettles(settleEdges),
            }}
            drag
            dragMomentum={false}
            onDragStart={() => {
              isDragging.current = true
              cancelFrame(settling.current)
            }}
            onDragEnd={(_event: PointerEvent, info: PanInfo) => {
              isDragging.current = false
              afterZoomSettles(() => settleEdges(info.velocity))
            }}
          />
        </motion.div>
        <div className="pz-footer">
          <div className="pz-caption">
            <span className="pz-title">Shinjuku</span>
            <span className="pz-meta">11:42 pm</span>
          </div>
          <div className="pz-status">
            <motion.span className="pz-hint" style={{ opacity: hintOpacity }}>
              Pinch or double tap
            </motion.span>
            <motion.span
              className="pz-zoom"
              aria-live="polite"
              style={{ opacity: zoomOpacity }}
            >
              {zoom}
            </motion.span>
          </div>
        </div>
        <Stylesheet />
      </div>
    </LazyMotion>
  )
}

function Stylesheet() {
  return (
    <style>{`
      .pz-viewer {
        width: min(340px, calc((100vh - 120px) * 0.75));
        padding: 8px;
        border-radius: 24px;
        background: #ffffff;
        box-shadow:
          0 0 0 1px rgba(24, 34, 46, 0.06),
          0 1px 2px rgba(24, 34, 46, 0.06),
          0 12px 32px rgba(24, 34, 46, 0.1);
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        user-select: none;
        -webkit-user-select: none;
      }

      .pz-frame {
        position: relative;
        aspect-ratio: 3 / 4;
        border-radius: 16px;
        overflow: hidden;
        background: #0b0d10;
        cursor: zoom-in;
        touch-action: none;
        -webkit-tap-highlight-color: transparent;
        isolation: isolate;
      }

      .pz-frame:focus-visible {
        outline: 2px solid #2563eb;
        outline-offset: 2px;
      }

      .pz-photo {
        width: 100%;
        height: 100%;
        object-fit: cover;
        display: block;
        cursor: grab;
      }

      .pz-photo:active {
        cursor: grabbing;
      }

      .pz-footer {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        padding: 12px 8px 6px;
      }

      .pz-caption {
        display: flex;
        flex-direction: column;
        gap: 2px;
      }

      .pz-title {
        font-size: 14px;
        font-weight: 600;
        color: #18222e;
      }

      .pz-meta {
        font-size: 13px;
        color: #9aa1ae;
      }

      .pz-status {
        position: relative;
        display: flex;
        align-items: center;
        justify-content: flex-end;
        min-width: 130px;
        height: 28px;
      }

      .pz-hint {
        font-size: 13px;
        color: #9aa1ae;
        white-space: nowrap;
      }

      .pz-zoom {
        position: absolute;
        right: 0;
        padding: 4px 10px;
        border-radius: 999px;
        background: #18222e;
        color: #ffffff;
        font-size: 13px;
        font-weight: 600;
        font-variant-numeric: tabular-nums;
      }
    `}</style>
  )
}