Skip to example

Pinch to rotate

An example of a two finger rotate gesture with Motion's pinch() function. 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.

JavaScript

Source code

<div class="pr-editor">
  <div class="pr-frame" aria-label="Twist with two fingers to rotate">
    <img
      class="pr-photo"
      src="/photos/japan/1.jpg"
      alt="A red neon sign in Tokyo"
      draggable="false"
    />
  </div>
  <div class="pr-controls">
    <span class="pr-angle" aria-live="polite">0°</span>
    <div
      class="pr-dial"
      role="slider"
      tabindex="0"
      aria-label="Rotation"
      aria-valuemin="0"
      aria-valuemax="359"
      aria-valuenow="0"
    >
      <div class="pr-ticks"></div>
      <div class="pr-needle"></div>
    </div>
    <span class="pr-hint">Twist with two fingers, or drag the dial</span>
  </div>
</div>

<script type="module">
  import {
    animate,
    motionValue,
    pinch,
    styleEffect,
    transformValue,
  } from "motion"

  const photo = document.querySelector(".pr-photo")
  const dial = document.querySelector(".pr-dial")
  const ticks = document.querySelector(".pr-ticks")
  const angle = document.querySelector(".pr-angle")

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

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

  /**
   * One rotate value drives the photo, the dial and the readout.
   * pinch() writes to it because it's bound to the photo with
   * styleEffect.
   */
  const rotate = motionValue(0)
  styleEffect(photo, { rotate })
  styleEffect(ticks, {
    backgroundPositionX: transformValue(
      // 50% puts the 0 tick under the needle
      () => `calc(50% - 0.75px + ${-rotate.get() * pixelsPerDegree}px)`,
    ),
  })

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

  rotate.on("change", (latest) => {
    const degrees = normalise(latest)
    angle.textContent = degrees + "°"
    dial.setAttribute("aria-valuenow", degrees)
  })

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

  /**
   * rotate.snap turns the photo to the nearest quarter turn when the
   * fingers lift. Scale and pan are off, so it only turns.
   */
  pinch(photo, { rotate: { snap: 90 }, scale: false, pan: false })

  /**
   * Trackpad pinches in Chrome and Firefox only report scale, so the
   * dial rotates the photo too.
   */
  dial.addEventListener("pointerdown", (event) => {
    rotate.stop()
    dial.setPointerCapture(event.pointerId)

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

    const move = (moveEvent) => {
      if (moveEvent.pointerId !== event.pointerId) return
      rotate.set(startRotate - (moveEvent.clientX - startX) / pixelsPerDegree)
    }
    const end = (endEvent) => {
      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)
  })

  dial.addEventListener("keydown", (event) => {
    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)
  })
</script>

<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;
    /* Ticks fade out at both ends */
    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;
  }

  /*
   * One tick every 5 degrees and a long one every 45, as a repeating
   * gradient. Moving its background position scrolls it forever.
   */
  .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>