Skip to example

Pinch to rotate

An example of a two finger rotate gesture with Motion for React's pinch prop. Twist the photo and it snaps to the nearest quarter turn when you let go. A dial turns it too, for trackpads that only report zoom.

React

Source code

"use client"

import {
  animate,
  LazyMotion,
  motion,
  pinchFeatures,
  useMotionValue,
  useMotionValueEvent,
  useTransform,
} from "motion/react"
import { useState } from "react"
import type { KeyboardEvent, PointerEvent } from "react"

const snappy = { type: "spring", stiffness: 420, damping: 32 } as const

// Pixels the dial moves for each degree of rotation
const pixelsPerDegree = 3

const normalise = (degrees: number) => ((Math.round(degrees) % 360) + 360) % 360

export default function PinchRotate() {
  /**
   * One rotate value drives the photo, the dial and the readout. The
   * pinch writes to it because it's the photo's style.rotate.
   */
  const rotate = useMotionValue(0)

  // 50% puts the 0 tick under the needle
  const ticksX = useTransform(
    () => `calc(50% - 0.75px + ${-rotate.get() * pixelsPerDegree}px)`,
  )

  const [degrees, setDegrees] = useState(0)
  useMotionValueEvent(rotate, "change", (latest) =>
    setDegrees(normalise(latest)),
  )

  // The dial snaps to the nearest quarter turn, like the pinch
  const snap = () => animate(rotate, Math.round(rotate.get() / 90) * 90, snappy)

  /**
   * Trackpad pinches in Chrome and Firefox only report scale, so the
   * dial rotates the photo too.
   */
  const onDialDown = (event: PointerEvent<HTMLDivElement>) => {
    const dial = event.currentTarget
    rotate.stop()
    dial.setPointerCapture(event.pointerId)

    const startX = event.clientX
    const startRotate = rotate.get()

    const move = (moveEvent: globalThis.PointerEvent) => {
      if (moveEvent.pointerId !== event.pointerId) return
      rotate.set(startRotate - (moveEvent.clientX - startX) / pixelsPerDegree)
    }
    const end = (endEvent: globalThis.PointerEvent) => {
      if (endEvent.pointerId !== event.pointerId) return
      window.removeEventListener("pointermove", move)
      window.removeEventListener("pointerup", end)
      window.removeEventListener("pointercancel", end)
      snap()
    }

    // The window, so the drag carries on once the pointer leaves the dial
    window.addEventListener("pointermove", move)
    window.addEventListener("pointerup", end)
    window.addEventListener("pointercancel", end)
  }

  const onDialKey = (event: KeyboardEvent) => {
    const direction =
      event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0
    if (!direction) return
    event.preventDefault()
    animate(rotate, Math.round(rotate.get() / 90) * 90 + direction * 90, snappy)
  }

  return (
    <LazyMotion features={pinchFeatures}>
      <div className="pr-editor">
        <div className="pr-frame" aria-label="Twist with two fingers to rotate">
          <motion.img
            className="pr-photo"
            src="/photos/japan/1.jpg"
            alt="A red neon sign in Tokyo"
            draggable={false}
            style={{ rotate }}
            // rotate.snap turns it to the nearest quarter turn on release
            pinch={{ rotate: { snap: 90 }, scale: false, pan: false }}
          />
        </div>
        <div className="pr-controls">
          <span className="pr-angle" aria-live="polite">
            {degrees}°
          </span>
          <div
            className="pr-dial"
            role="slider"
            tabIndex={0}
            aria-label="Rotation"
            aria-valuemin={0}
            aria-valuemax={359}
            aria-valuenow={degrees}
            onPointerDown={onDialDown}
            onKeyDown={onDialKey}
          >
            <motion.div
              className="pr-ticks"
              style={{ backgroundPositionX: ticksX }}
            />
            <div className="pr-needle" />
          </div>
          <span className="pr-hint">
            Twist with two fingers, or drag the dial
          </span>
        </div>
        <Stylesheet />
      </div>
    </LazyMotion>
  )
}

function Stylesheet() {
  return (
    <style>{`
      .pr-editor {
        width: min(340px, calc(100vh - 220px));
        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;
      }

      .pr-frame {
        aspect-ratio: 1;
        border-radius: 16px;
        background:
          radial-gradient(circle, rgba(24, 34, 46, 0.08) 1px, transparent 1.5px)
            0 0 / 12px 12px,
          #f1f3f6;
        display: flex;
        align-items: center;
        justify-content: center;
        touch-action: none;
      }

      .pr-photo {
        width: 72%;
        aspect-ratio: 1;
        object-fit: cover;
        border-radius: 12px;
        box-shadow:
          0 1px 2px rgba(24, 34, 46, 0.12),
          0 12px 28px rgba(24, 34, 46, 0.22);
        display: block;
      }

      .pr-controls {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 10px;
        padding: 14px 8px 8px;
      }

      .pr-angle {
        font-size: 15px;
        font-weight: 600;
        color: #18222e;
        font-variant-numeric: tabular-nums;
      }

      .pr-dial {
        position: relative;
        width: 100%;
        height: 28px;
        cursor: ew-resize;
        touch-action: none;
        mask-image: linear-gradient(
          to right,
          transparent,
          #000 25%,
          #000 75%,
          transparent
        );
      }

      .pr-dial:focus-visible {
        outline: 2px solid #2563eb;
        outline-offset: 4px;
        border-radius: 6px;
      }

      .pr-ticks {
        position: absolute;
        inset: 0;
        background-image:
          linear-gradient(to right, #18222e 1.5px, transparent 1.5px),
          linear-gradient(to right, #c3c8d2 1px, transparent 1px);
        background-size:
          135px 18px,
          15px 12px;
        background-repeat: repeat-x;
        background-position: calc(50% - 0.75px) center;
      }

      .pr-needle {
        position: absolute;
        left: 50%;
        top: 0;
        bottom: 0;
        width: 2px;
        margin-left: -1px;
        border-radius: 1px;
        background: #ff3b5c;
      }

      .pr-hint {
        font-size: 13px;
        color: #9aa1ae;
      }
    `}</style>
  )
}