Skip to example

Pinch to zoom

An example of pinch to zoom with Motion's pinch() function. Pinch the photo to zoom around your fingers, drag to look around, or double tap to zoom in on a point. Let go and it springs back inside the frame.

JavaScript

Source code

<div class="pz-viewer">
  <div class="pz-frame" aria-label="Pinch or double tap to zoom">
    <img
      class="pz-photo"
      src="/photos/japan/5.jpg"
      alt="A street in Tokyo at night"
      draggable="false"
    />
  </div>
  <div class="pz-footer">
    <div class="pz-caption">
      <span class="pz-title">Shinjuku</span>
      <span class="pz-meta">11:42 pm</span>
    </div>
    <div class="pz-status">
      <span class="pz-hint">Pinch or double tap</span>
      <span class="pz-zoom" aria-live="polite">1.0×</span>
    </div>
  </div>
</div>

<script type="module">
  import {
    animate,
    cancelFrame,
    drag,
    frame,
    mapValue,
    motionValue,
    pinch,
    press,
    styleEffect,
  } from "motion"

  const viewer = document.querySelector(".pz-frame")
  const photo = document.querySelector(".pz-photo")
  const zoomLabel = document.querySelector(".pz-zoom")

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

  /**
   * pinch(), drag() and animate() all write to the photo's scale, x and
   * y. Binding our own motion values with styleEffect means we can read
   * and animate the same values they use.
   */
  const scale = motionValue(1)
  const x = motionValue(0)
  const y = motionValue(0)
  styleEffect(photo, { scale, x, y })

  styleEffect(document.querySelector(".pz-hint"), {
    opacity: mapValue(scale, [1, 1.15], [1, 0]),
  })
  styleEffect(zoomLabel, {
    opacity: mapValue(scale, [1, 1.15], [0, 1]),
  })
  scale.on("change", (latest) => {
    zoomLabel.textContent = latest.toFixed(1) + "×"
  })

  /**
   * How far the photo can move at a given scale before an edge comes
   * inside the frame.
   */
  function limit(size, s) {
    return Math.max(0, (size * (s - 1)) / 2)
  }

  function clampPan(value, size, s) {
    const max = limit(size, s)
    return Math.min(max, Math.max(-max, value))
  }

  /**
   * 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.
   */
  function settleEdges(velocity = { x: 0, y: 0 }) {
    const s = scale.get()
    const { offsetWidth: width, offsetHeight: height } = viewer

    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
  let settling = () => {}

  function afterZoomSettles(callback) {
    cancelFrame(settling)
    let last
    const check = (settling = () => {
      const latest = scale.get()
      if (latest === last) {
        cancelFrame(check)
        callback()
      }
      last = latest
    })
    frame.postRender(check, true)
  }

  let isDragging = false

  /**
   * Past 1x or 4x the pinch resists, and springs back inside them when
   * the fingers lift, around the point between them.
   */
  pinch(
    photo,
    () => {
      // A new pinch takes over from a settle still waiting to run
      cancelFrame(settling)

      return () => {
        // A finger still down carries on as a drag, which settles on release
        if (!isDragging) afterZoomSettles(settleEdges)
      }
    },
    { scale: { min: 1, max: 4 }, rotate: false },
  )

  drag(photo, {
    x,
    y,
    momentum: false,
    onStart: () => {
      isDragging = true
      cancelFrame(settling)
    },
    onEnd: (_event, info) => {
      isDragging = false
      afterZoomSettles(() => settleEdges(info.velocity))
    },
  })

  /**
   * Double tap zooms in on the point that was tapped, or back out.
   */
  press(
    viewer,
    () =>
      (endEvent, { success }) => {
        if (!success) return

        const current = scale.get()
        const target = current > 1.05 ? 1 : 2.5
        const rect = viewer.getBoundingClientRect()

        // The tapped point, from the frame's centre. Keyboard presses
        // have no pointer position, so they zoom on the centre.
        const isPointer = endEvent.clientX || endEvent.clientY
        const px = isPointer ? endEvent.clientX - rect.left - rect.width / 2 : 0
        const py = isPointer ? endEvent.clientY - rect.top - rect.height / 2 : 0

        // Keep the tapped point under the finger as the scale changes
        const ratio = target / current
        const nextX = px - (px - x.get()) * ratio
        const nextY = py - (py - y.get()) * ratio

        animate(scale, target, ui)
        animate(x, clampPan(nextX, rect.width, target), ui)
        animate(y, clampPan(nextY, rect.height, target), ui)
      },
    { count: 2 },
  )
</script>

<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;
    /* Keeps the scaled photo clipped to the rounded corners in Safari */
    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;
    opacity: 0;
  }
</style>