Skip to example

Double tap to like

An example of a double tap gesture with Motion's press() function. The count option makes the press only succeed after two quick presses, which pops a heart where the photo was tapped.

JavaScript

Source code

<div class="dt-post">
  <div class="dt-photo" aria-label="Double tap to like">
    <img src="/photos/prague/image-02.jpg" alt="" draggable="false" />
  </div>
  <div class="dt-footer">
    <button class="dt-like" aria-label="Like" aria-pressed="false">
      <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
        <path
          d="M12 20.5s-7.5-4.6-9.3-9.2C1.4 8 3.4 4.5 6.9 4.5c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.5 3.5 4.2 6.8-1.8 4.6-9.3 9.2-9.3 9.2z"
          stroke-width="1.75"
          stroke-linejoin="round"
        />
      </svg>
    </button>
    <div class="dt-count">
      <span class="dt-number"><span>1,248</span></span>
      <span class="dt-unit">likes</span>
    </div>
    <span class="dt-hint">Double tap the photo</span>
  </div>
</div>

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

  const photo = document.querySelector(".dt-photo")
  const likeButton = document.querySelector(".dt-like")
  const likePath = likeButton.querySelector("path")
  const number = document.querySelector(".dt-number")

  const ui = { type: "spring", stiffness: 305, damping: 33 }
  const lively = { type: "spring", stiffness: 622, damping: 17 }
  const red = "#ff3b5c"
  const heartPath = likePath.getAttribute("d")

  let isLiked = false

  function setLiked(liked) {
    if (liked === isLiked) return
    isLiked = liked
    likeButton.setAttribute("aria-pressed", liked)
    likeButton.setAttribute("aria-label", liked ? "Unlike" : "Like")

    animate(
      likePath,
      {
        fill: liked ? red : "rgba(255, 59, 92, 0)",
        stroke: liked ? red : "#18222e",
      },
      { duration: 0.2 },
    )
    if (liked) animate(likeButton, { scale: [1, 1.35, 1] }, lively)

    // Roll the count: the old number leaves, the new one follows
    const direction = liked ? 1 : -1
    const previous = number.firstElementChild
    const next = document.createElement("span")
    next.textContent = liked ? "1,249" : "1,248"
    number.append(next)
    animate(
      previous,
      { y: -10 * direction, opacity: 0, filter: "blur(4px)" },
      ui,
    ).then(() => previous.remove())
    animate(
      next,
      {
        y: [10 * direction, 0],
        opacity: [0, 1],
        filter: ["blur(4px)", "blur(0px)"],
      },
      ui,
    )
  }

  function burst(x, y) {
    const container = document.createElement("div")
    container.className = "dt-burst"
    container.style.left = x + "px"
    container.style.top = y + "px"
    container.innerHTML = `<svg class="dt-heart" width="96" height="96" viewBox="0 0 24 24" aria-hidden="true"><path d="${heartPath}" fill="#ffffff" /></svg>`
    photo.append(container)

    for (let i = 0; i < 8; i++) {
      const angle = (i / 8) * Math.PI * 2 - Math.PI / 2
      const particle = document.createElement("span")
      particle.className = "dt-particle"
      container.append(particle)
      animate(
        particle,
        {
          x: Math.cos(angle) * 64,
          y: Math.sin(angle) * 64,
          scale: [1, 0],
          opacity: [1, 1, 0],
        },
        { duration: 0.6, ease: [0.2, 0, 0, 1], delay: 0.06 },
      )
    }

    const heart = container.firstElementChild
    // A bouncy spring overshoots, so the heart pops
    animate(
      heart,
      {
        scale: [0, 1],
        opacity: [0, 1],
        rotate: [0, Math.random() * 30 - 15],
      },
      {
        type: "spring",
        visualDuration: 0.3,
        bounce: 0.6,
        opacity: { duration: 0.1 },
      },
    )
    // The exit runs on the container, so it doesn't stop the pop
    animate(
      container,
      { y: -48, scale: 0.7, opacity: 0 },
      { duration: 0.35, ease: "easeIn", delay: 0.5 },
    ).then(() => container.remove())
  }

  /**
   * With count: 2, the gesture only succeeds after a second,
   * quick press close to the first.
   */
  press(
    photo,
    () =>
      (endEvent, { success }) => {
        if (!success) return

        const rect = photo.getBoundingClientRect()
        let x = endEvent.clientX - rect.left
        let y = endEvent.clientY - rect.top

        // Keyboard presses have no pointer position, so burst from the centre
        if (x <= 0 || y <= 0 || x >= rect.width || y >= rect.height) {
          x = rect.width / 2
          y = rect.height / 2
        }

        burst(x, y)
        setLiked(true)
      },
    { count: 2 },
  )

  press(likeButton, () => {
    animate(likeButton, { scale: 0.85 }, ui)

    return (_event, { success }) => {
      animate(likeButton, { scale: 1 }, ui)
      if (success) setLiked(!isLiked)
    }
  })
</script>

<style>
  .dt-post {
    width: min(300px, calc(100vh - 140px));
    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;
  }

  .dt-photo {
    position: relative;
    aspect-ratio: 1;
    border-radius: 16px;
    overflow: hidden;
    cursor: pointer;
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
  }

  .dt-photo:focus-visible {
    outline: 2px solid #2563eb;
    outline-offset: 2px;
  }

  .dt-photo img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    pointer-events: none;
  }

  .dt-burst {
    position: absolute;
    width: 0;
    height: 0;
    pointer-events: none;
  }

  .dt-heart {
    opacity: 0;
    transform: scale(0);
    position: absolute;
    left: -48px;
    top: -48px;
    filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.25));
  }

  .dt-particle {
    position: absolute;
    left: -4px;
    top: -4px;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #ffffff;
  }

  .dt-footer {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 8px 6px;
  }

  .dt-like {
    width: 36px;
    height: 36px;
    margin: -4px;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: transparent;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
  }

  .dt-like:focus-visible {
    outline: 2px solid #2563eb;
  }

  .dt-count {
    position: relative;
    display: flex;
    gap: 4px;
    font-size: 14px;
    font-weight: 600;
    color: #18222e;
    font-variant-numeric: tabular-nums;
  }

  .dt-number {
    position: relative;
    display: inline-block;
    min-width: 3.2em;
    height: 1.2em;
    overflow: hidden;
  }

  .dt-number span {
    position: absolute;
    left: 0;
    top: 0;
  }

  .dt-unit {
    font-weight: 400;
    color: #60687a;
  }

  .dt-hint {
    margin-left: auto;
    font-size: 13px;
    color: #9aa1ae;
    white-space: nowrap;
  }

  @media (max-height: 420px) {
    .dt-hint {
      display: none;
    }
  }

  .dt-like path {
    fill: rgba(255, 59, 92, 0);
    stroke: #18222e;
  }
</style>