Skip to example

Double tap to like

An example of a double tap gesture with Motion for React. The tap prop's count option makes onTap fire only after two quick taps, which pops a heart where the photo was tapped.

React

Source code

"use client"

import { AnimatePresence, motion, useAnimate } from "motion/react"
import type { TapInfo } 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 red = "#ff3b5c"

interface Burst {
  id: number
  x: number
  y: number
  rotate: number
}

export default function DoubleTap() {
  const photo = useRef<HTMLDivElement>(null)
  const [likeButton, animateLike] = useAnimate()
  const [isLiked, setIsLiked] = useState(false)
  const [bursts, setBursts] = useState<Burst[]>([])
  const nextId = useRef(0)

  const setLiked = (liked: boolean) => {
    setIsLiked(liked)
    if (liked) {
      animateLike(likeButton.current, { scale: [1, 1.35, 1] }, lively)
    }
  }

  /**
   * With tap={{ count: 2 }}, onTap only fires after a second,
   * quick tap close to the first.
   */
  const onDoubleTap = (_event: PointerEvent, info: TapInfo) => {
    const rect = photo.current!.getBoundingClientRect()
    let x = info.point.x - window.scrollX - rect.left
    let y = info.point.y - window.scrollY - 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
    }

    setBursts((current) => [
      ...current,
      { id: nextId.current++, x, y, rotate: Math.random() * 30 - 15 },
    ])
    setLiked(true)
  }

  return (
    <div className="dt-post">
      <motion.div
        ref={photo}
        className="dt-photo"
        tap={{ count: 2 }}
        onTap={onDoubleTap}
        aria-label="Double tap to like"
      >
        <img src="/photos/prague/image-02.jpg" alt="" draggable={false} />
        {bursts.map((burst) => (
          <HeartBurst
            key={burst.id}
            {...burst}
            onDone={() =>
              setBursts((current) => current.filter((b) => b.id !== burst.id))
            }
          />
        ))}
      </motion.div>
      <div className="dt-footer">
        <motion.button
          ref={likeButton}
          className="dt-like"
          onTap={() => setLiked(!isLiked)}
          whileTap={{ scale: 0.85 }}
          transition={ui}
          aria-label={isLiked ? "Unlike" : "Like"}
          aria-pressed={isLiked}
        >
          <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
            <motion.path
              d={heartPath}
              initial={false}
              animate={{
                fill: isLiked ? red : "rgba(255, 59, 92, 0)",
                stroke: isLiked ? red : "#18222e",
              }}
              transition={{ duration: 0.2 }}
              strokeWidth={1.75}
              strokeLinejoin="round"
            />
          </svg>
        </motion.button>
        <div className="dt-count">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={isLiked ? "liked" : "unliked"}
              initial={{
                y: isLiked ? 10 : -10,
                opacity: 0,
                filter: "blur(4px)",
              }}
              animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
              exit={{ y: isLiked ? -10 : 10, opacity: 0, filter: "blur(4px)" }}
              transition={ui}
            >
              {isLiked ? "1,249" : "1,248"}
            </motion.span>
          </AnimatePresence>
          <span className="dt-unit">likes</span>
        </div>
        <span className="dt-hint">Double tap the photo</span>
      </div>
      <Stylesheet />
    </div>
  )
}

function HeartBurst({ x, y, rotate, onDone }: Burst & { onDone: () => void }) {
  const [scope, animate] = useAnimate()
  const heart = useRef<SVGSVGElement>(null)

  useEffect(() => {
    const element = heart.current!
    // A bouncy spring overshoots, so the heart pops
    animate(
      element,
      { scale: [0, 1], opacity: [0, 1], rotate: [0, rotate] },
      {
        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(
      scope.current,
      { y: -48, scale: 0.7, opacity: 0 },
      { duration: 0.35, ease: "easeIn", delay: 0.5 },
    ).then(onDone)
  }, [])

  return (
    <div ref={scope} className="dt-burst" style={{ left: x, top: y }}>
      {particles.map((angle, i) => (
        <motion.span
          key={i}
          className="dt-particle"
          initial={{ x: 0, y: 0, scale: 1, opacity: 1 }}
          animate={{
            x: Math.cos(angle) * 64,
            y: Math.sin(angle) * 64,
            scale: 0,
            opacity: [1, 1, 0],
          }}
          transition={{ duration: 0.6, ease: [0.2, 0, 0, 1], delay: 0.06 }}
        />
      ))}
      <svg
        ref={heart}
        className="dt-heart"
        width="96"
        height="96"
        viewBox="0 0 24 24"
        aria-hidden="true"
      >
        <path d={heartPath} fill="#ffffff" />
      </svg>
    </div>
  )
}

const heartPath =
  "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"

const particles = Array.from(
  { length: 8 },
  (_, i) => (i / 8) * Math.PI * 2 - Math.PI / 2,
)

function Stylesheet() {
  return (
    <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-count > span:first-child {
        display: inline-block;
      }

      .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;
        }
      }
    `}</style>
  )
}