Skip to example

Long press

An example of a long press with Motion's press() function. The hold option sets how long the press must be held, and onHold reports progress from 0 to 1 to fill a ring before the lock opens.

JavaScript

Source code

<div class="lp-lock">
  <div class="lp-dial">
    <svg
      class="lp-ring"
      width="160"
      height="160"
      viewBox="0 0 160 160"
      aria-hidden="true"
    >
      <circle class="lp-track" cx="80" cy="80" r="72" />
      <circle class="lp-progress" cx="80" cy="80" r="72" />
    </svg>
    <button class="lp-button" aria-label="Hold to unlock">
      <svg
        class="lp-icon"
        width="40"
        height="40"
        viewBox="0 0 24 24"
        aria-hidden="true"
      >
        <path class="lp-shackle" d="M8 11V7.5a4 4 0 0 1 8 0V11" />
        <rect class="lp-body" x="5" y="11" width="14" height="10" rx="2.5" />
        <circle class="lp-keyhole" cx="12" cy="16" r="1.25" />
      </svg>
    </button>
  </div>
  <p class="lp-label" aria-live="polite">Hold to unlock</p>
</div>

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

  const button = document.querySelector(".lp-button")
  const ring = document.querySelector(".lp-ring")
  const progressCircle = document.querySelector(".lp-progress")
  const shackle = document.querySelector(".lp-shackle")
  const label = document.querySelector(".lp-label")

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

  const circumference = 2 * Math.PI * 72
  progressCircle.style.strokeDasharray = circumference

  /**
   * onHold reports 0 to 1 over the hold time. Everything that
   * builds up during the hold reads from this one motion value.
   */
  const progress = motionValue(0)

  styleEffect(progressCircle, {
    strokeDashoffset: mapValue(progress, [0, 1], [circumference, 0]),
    opacity: mapValue(progress, [0, 0.05], [0, 1]),
  })
  styleEffect(shackle, {
    y: mapValue(progress, [0, 1], [0, -1]),
  })

  let isUnlocked = false

  let labelText = label.textContent

  function setLabel(text) {
    if (text === labelText) return
    labelText = text

    animate(
      label,
      { opacity: 0, filter: "blur(4px)", y: -4 },
      { duration: 0.12 },
    ).then(() => {
      if (text !== labelText) return
      label.textContent = text
      animate(
        label,
        { opacity: [0, 1], filter: ["blur(4px)", "blur(0px)"], y: [4, 0] },
        ui,
      )
    })
  }

  function unlock() {
    isUnlocked = true
    navigator.vibrate?.(12)
    button.classList.add("lp-unlocked")
    animate(shackle, { y: -4 }, lively)
    animate(
      ring,
      { scale: [1, 1.1], opacity: [1, 0] },
      { duration: 0.5, ease: [0.2, 0, 0, 1] },
    )
    setLabel("Unlocked")

    setTimeout(lock, 1800)
  }

  function lock() {
    button.classList.remove("lp-unlocked")
    progress.set(0)
    animate(shackle, { y: 0 }, lively)
    animate(ring, { scale: 1, opacity: 1 }, { duration: 0 })
    setLabel("Hold to unlock")
    isUnlocked = false
  }

  press(
    button,
    () => {
      if (isUnlocked) return

      animate(button, { scale: 0.92 }, ui)
      setLabel("Keep holding")

      return {
        onHold: (p) => progress.set(p),
        onActive: unlock,
        onComplete: (_event, { success }) => {
          animate(button, { scale: 1 }, lively)

          if (!success) {
            animate(progress, 0, ui)
            setLabel("Hold to unlock")
          }
        },
      }
    },
    { hold: 1 },
  )
</script>

<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-progress {
    stroke: #18222e;
    transition: stroke 0.2s;
  }

  .lp-button {
    width: 120px;
    height: 120px;
    border-radius: 50%;
    border: none;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    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;
    transition:
      background-color 0.3s,
      color 0.3s;
  }

  .lp-button.lp-unlocked {
    background: #2fbf71;
    color: #ffffff;
  }

  .lp-dial:has(.lp-unlocked) .lp-progress {
    stroke: #2fbf71;
  }

  .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>