Skip to example

Long press

An example of a long press with Motion for React. The tap prop's hold option makes onTap fire only once the press is held for a second, while the ring fills and the lock opens.

React

Source code

"use client"

import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"

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

const hold = 1
const circumference = 2 * Math.PI * 72

type State = "locked" | "holding" | "unlocked"

const ringState: Record<State, string> = {
  locked: "rest",
  holding: "pressed",
  unlocked: "unlocked",
}

const labels: Record<State, string> = {
  locked: "Hold to unlock",
  holding: "Keep holding",
  unlocked: "Unlocked",
}

export default function LongPress() {
  const [state, setState] = useState<State>("locked")

  // After an unlock the ring resets at once, not with a spring
  const wasUnlocked = useRef(false)
  useEffect(() => {
    wasUnlocked.current = state === "unlocked"
  }, [state])

  const unlock = () => {
    navigator.vibrate?.(12)
    setState("unlocked")
  }

  // Lock again a moment after unlocking
  useEffect(() => {
    if (state !== "unlocked") return
    const timeout = setTimeout(() => setState("locked"), 1800)
    return () => clearTimeout(timeout)
  }, [state])

  const isUnlocked = state === "unlocked"

  return (
    <div className="lp-lock">
      <div className="lp-dial">
        <motion.svg
          className="lp-ring"
          width="160"
          height="160"
          viewBox="0 0 160 160"
          aria-hidden="true"
          initial={false}
          animate={isUnlocked ? "unlocked" : "rest"}
          variants={{
            rest: { scale: 1, opacity: 1, transition: { duration: 0 } },
            // The ring pulses out as the lock opens
            unlocked: {
              scale: [1, 1.1],
              opacity: [1, 0],
              transition: { duration: 0.5, ease: [0.2, 0, 0, 1] },
            },
          }}
        >
          <circle className="lp-track" cx="80" cy="80" r="72" />
          <motion.circle
            className="lp-progress"
            cx="80"
            cy="80"
            r="72"
            strokeDasharray={circumference}
            // An attribute, not a class rule, so the unlock can turn it green
            stroke="#18222e"
            initial={false}
            animate={ringState[state]}
            custom={wasUnlocked.current}
            variants={{
              rest: (fromUnlocked: boolean) => ({
                strokeDashoffset: circumference,
                opacity: 0,
                stroke: "#18222e",
                transition: fromUnlocked ? { duration: 0 } : ui,
              }),
              pressed: {
                strokeDashoffset: 0,
                opacity: 1,
                transition: {
                  strokeDashoffset: { duration: hold, ease: "linear" },
                  opacity: { duration: 0.1 },
                },
              },
              unlocked: {
                strokeDashoffset: 0,
                opacity: 1,
                stroke: "#2fbf71",
                transition: { duration: 0.2 },
              },
            }}
            // The ring is full after `hold` seconds: unlock while still held
            onAnimationComplete={(definition) =>
              definition === "pressed" && unlock()
            }
          />
        </motion.svg>
        {/*
         * tap={{ hold }} makes a press shorter than `hold` seconds a
         * cancel, so onTapCancel fires and the ring springs back.
         * onTapStart starts filling the ring over the same time.
         */}
        <motion.button
          className="lp-button"
          aria-label="Hold to unlock"
          tap={{ hold }}
          onTapStart={() => !isUnlocked && setState("holding")}
          onTapCancel={() => !isUnlocked && setState("locked")}
          initial={false}
          animate={isUnlocked ? "unlocked" : "rest"}
          whileTap={isUnlocked ? undefined : "pressed"}
          variants={{
            rest: { scale: 1, backgroundColor: "#ffffff", color: "#18222e" },
            pressed: { scale: 0.92 },
            unlocked: {
              scale: 1,
              backgroundColor: "#2fbf71",
              color: "#ffffff",
            },
          }}
          // Colours fade like the JS version's CSS transition, without
          // the spring's overshoot
          transition={{
            ...lively,
            backgroundColor: { duration: 0.3 },
            color: { duration: 0.3 },
          }}
        >
          <svg
            className="lp-icon"
            width="40"
            height="40"
            viewBox="0 0 24 24"
            aria-hidden="true"
          >
            <motion.path
              className="lp-shackle"
              d="M8 11V7.5a4 4 0 0 1 8 0V11"
              variants={{
                rest: { y: 0 },
                pressed: { y: -1 },
                unlocked: { y: -4 },
              }}
              transition={lively}
            />
            <rect
              className="lp-body"
              x="5"
              y="11"
              width="14"
              height="10"
              rx="2.5"
            />
            <circle className="lp-keyhole" cx="12" cy="16" r="1.25" />
          </svg>
        </motion.button>
      </div>
      <motion.p
        key={state}
        className="lp-label"
        aria-live="polite"
        initial={{ opacity: 0, filter: "blur(4px)", y: 4 }}
        animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
        transition={ui}
      >
        {labels[state]}
      </motion.p>
      <Stylesheet />
    </div>
  )
}

function Stylesheet() {
  return (
    <style>{`
      .lp-lock {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 20px;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        user-select: none;
        -webkit-user-select: none;
      }

      .lp-dial {
        position: relative;
        width: 160px;
        height: 160px;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      .lp-ring {
        position: absolute;
        inset: 0;
        pointer-events: none;
      }

      .lp-ring circle {
        transform: rotate(-90deg);
        transform-origin: center;
        fill: none;
        stroke-width: 5;
        stroke-linecap: round;
      }

      .lp-track {
        stroke: #eceef2;
      }

      .lp-button {
        width: 120px;
        height: 120px;
        border-radius: 50%;
        border: none;
        padding: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        box-sizing: border-box;
        background: #ffffff;
        color: #18222e;
        box-shadow:
          0 0 0 1px rgba(24, 34, 46, 0.06),
          0 1px 2px rgba(24, 34, 46, 0.06),
          0 8px 24px rgba(24, 34, 46, 0.08);
        cursor: pointer;
        touch-action: none;
        -webkit-touch-callout: none;
      }

      .lp-button:focus-visible {
        outline: 2px solid #2563eb;
        outline-offset: 6px;
      }

      .lp-icon {
        overflow: visible;
      }

      .lp-shackle,
      .lp-body {
        fill: none;
        stroke: currentColor;
        stroke-width: 1.75;
        stroke-linecap: round;
        stroke-linejoin: round;
      }

      .lp-keyhole {
        fill: currentColor;
      }

      .lp-label {
        margin: 0;
        font-size: 15px;
        font-weight: 500;
        letter-spacing: -0.01em;
        color: #60687a;
      }
    `}</style>
  )
}