← Gestures

Double-tap Heart

Double-tap a photo and a big heart bursts out right where you tapped. It pops up with a wobble, throws off a spray of little hearts and floats away, while the like button below fills in and the count ticks up.

lake.days

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-double-tap-heart to the photo (or its wrapper).
  3. Optionally point data-like at your like button, next to the photo. Put data-count on the number inside it.

Options

Add any of these attributes to the same element.

data-likeA CSS selector for a like button, looked up next to the photo. It gets aria-pressed and the class dbh-liked, its [data-count] goes up by one, and clicking it toggles the like.
data-colorThe big heart’s colour. Default white.
data-sizeThe big heart’s size in px. Default a third of the photo’s shorter side.
data-autoplayA pretend finger double-taps every so many ms while in view. Default 2600. A real tap pauses it.

Double-clicking works with a mouse. A double-tap only ever likes, never unlikes, as in photo apps. It fires like with detail.liked whenever the like changes.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Double-tap Heart: double-tap (or double-click) a photo and a big heart
    // bursts out where you tapped: it pops up with a wobble, throws off a few
    // little hearts, then floats away. It can also fill in a like button and
    // count the like, the way photo apps do.
    // Usage: <div data-double-tap-heart><img src="photo.jpg" alt="…"></div>
    // Optional: data-like (a CSS selector for a like button, looked up in the
    // photo's parent; it gets aria-pressed and the class dbh-liked, its
    // [data-count] goes up by one, and clicking it toggles the like),
    // data-color (the big heart's colour, default white), data-size (its size
    // in px, default a third of the photo's shorter side), data-autoplay (a
    // pretend finger double-taps every so many ms while in view, default 2600;
    // a real tap pauses it).
    // Events: "like" with detail.liked, whenever the like changes.
    var DEFAULTS = { color: '#fff', autoplay: 2600 };
    var HEART = 'M12 21.3l-1.4-1.3C5.4 15.4 2 12.3 2 8.5 2 5.4 4.4 3 7.5 3c1.7 0 3.4.8 4.5 2.1C13.1 3.8 14.8 3 16.5 3 19.6 3 22 5.4 22 8.5c0 3.8-3.4 6.9-8.6 11.5L12 21.3z';

    var css =
      '.dbh{position:relative;overflow:hidden;touch-action:manipulation;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}' +
      '.dbh img{-webkit-user-drag:none;pointer-events:none}' +
      '.dbh-heart{position:absolute;left:0;top:0;pointer-events:none;z-index:4;overflow:visible;filter:drop-shadow(0 6px 14px rgba(0,0,0,.28))}' +
      '.dbh-mini{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;pointer-events:none;z-index:3}' +
      '.dbh-finger{position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;' +
      'background:rgba(255,255,255,.35);border:2px solid rgba(255,255,255,.9);box-shadow:0 2px 10px rgba(0,0,0,.25);pointer-events:none;opacity:0;z-index:5}' +
      '.dbh-liked svg path{fill:#f43f5e;stroke:#f43f5e}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var MINI = ['#f43f5e', '#fb7185', '#ec4899', '#f97316', '#a855f7'];
    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function svg(cls, fill, size) {
      var n = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      n.setAttribute('class', cls);
      n.setAttribute('viewBox', '0 0 24 24');
      n.setAttribute('aria-hidden', 'true');
      if (size) { n.setAttribute('width', size); n.setAttribute('height', size); }
      n.innerHTML = '<path d="' + HEART + '" fill="' + fill + '"/>';
      return n;
    }

    function setup(el) {
      var d = el.dataset;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('dbh');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var color = d.color || DEFAULTS.color;
      var btn = d.like && el.parentElement ? el.parentElement.querySelector(d.like) : null;
      var count = btn ? btn.querySelector('[data-count]') : null;
      var base = count ? parseInt(count.textContent.replace(/[^0-9]/g, ''), 10) || 0 : 0;
      var s = el._dbh = { liked: false, userAt: -1e9 };

      function setLiked(on, quiet) {
        if (s.liked === on) return;
        s.liked = on;
        if (btn) {
          btn.classList.toggle('dbh-liked', on);
          btn.setAttribute('aria-pressed', on ? 'true' : 'false');
          var icon = btn.querySelector('svg') || btn;
          if (on && !reduced() && !quiet) anime.animate(icon, { scale: [0.6, 1.25, 1], duration: 450, ease: 'out(3)' });
        }
        if (count) count.textContent = (base + (on ? 1 : 0)).toLocaleString();
        el.dispatchEvent(new CustomEvent('like', { detail: { liked: on } }));
      }
      s.setLiked = setLiked;
      if (btn) {
        btn.setAttribute('aria-pressed', 'false');
        if (!thumb) btn.addEventListener('click', function () { s.userAt = performance.now(); setLiked(!s.liked); });
      }

      function burst(x, y) {
        setLiked(true);
        if (reduced()) return;
        var size = +d.size || Math.round(Math.min(el.clientWidth, el.clientHeight) / 3);
        var h = svg('dbh-heart', color, size);
        el.appendChild(h);
        var tilt = anime.utils.random(-14, 14);
        anime.utils.set(h, { translateX: x - size / 2, translateY: y - size / 2, scale: 0, rotate: tilt });
        anime.createTimeline({ onComplete: function () { h.remove(); } })
          .add(h, { scale: [{ to: 1.25, duration: 200, ease: 'out(3)' }, { to: 0.92, duration: 140, ease: 'inOut(2)' }, { to: 1, duration: 260, ease: 'outElastic(1, .6)' }] })
          .add(h, { translateY: y - size / 2 - size * 0.55, scale: 0.7, opacity: 0, rotate: tilt * 0.3, duration: 420, ease: 'in(2)' }, '+=260');
        var count2 = 7;
        for (var i = 0; i < count2; i++) {
          var m = svg('dbh-mini', MINI[i % MINI.length]);
          el.appendChild(m);
          var a = -Math.PI / 2 + (i - (count2 - 1) / 2) * 0.48 + anime.utils.random(-12, 12) / 100;
          var dist = size * anime.utils.random(55, 85) / 100;
          anime.utils.set(m, { translateX: x, translateY: y, scale: 0 });
          anime.animate(m, {
            translateX: x + Math.cos(a) * dist,
            translateY: y + Math.sin(a) * dist,
            scale: [{ to: anime.utils.random(70, 120) / 100, duration: 220, ease: 'out(2)' }, { to: 0.3, duration: 380, ease: 'in(2)' }],
            opacity: [{ to: 1, duration: 120 }, { to: 0, duration: 300, delay: 300 }],
            rotate: anime.utils.random(-40, 40),
            delay: 90,
            duration: 600,
            ease: 'out(3)',
            onComplete: (function (node) { return function () { node.remove(); }; })(m),
          });
        }
      }
      s.burst = burst;

      var lastTap = null;
      function tap(x, y) {
        var now = performance.now();
        if (lastTap && now - lastTap.t < 320 && Math.hypot(x - lastTap.x, y - lastTap.y) < 40) {
          lastTap = null;
          burst(x, y);
        } else {
          lastTap = { t: now, x: x, y: y };
        }
      }

      if (!thumb) {
        el.addEventListener('pointerdown', function (e) {
          if (e.button) return;
          s.userAt = performance.now();
          hideFinger();
          var r = el.getBoundingClientRect();
          tap(e.clientX - r.left, e.clientY - r.top);
        });
        el.addEventListener('dblclick', function (e) { e.preventDefault(); });
      }

      var finger = null;
      function hideFinger() { if (finger) anime.animate(finger, { opacity: 0, duration: 150 }); }
      if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
      var every = +d.autoplay || DEFAULTS.autoplay;
      finger = document.createElement('span');
      finger.className = 'dbh-finger';
      finger.setAttribute('aria-hidden', 'true');
      el.appendChild(finger);
      var visible = false, timer = 0;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (visible) timer = setTimeout(play, 700);
      }).observe(el);

      function play() {
        if (!visible) return;
        if (performance.now() - s.userAt < 6000) { timer = setTimeout(play, 1000); return; }
        setLiked(false, true);   // start fresh, so the button fills again
        var w = el.clientWidth, h = el.clientHeight;
        var x = w * anime.utils.random(32, 68) / 100, y = h * anime.utils.random(40, 62) / 100;
        anime.utils.set(finger, { x: x, y: y });
        var n = 0;
        (function one() {
          anime.animate(finger, { opacity: [0.2, 1], scale: [1.35, 0.85], duration: 140, ease: 'out(2)' });
          timer = setTimeout(function () {
            anime.animate(finger, { opacity: n ? 0 : 0.5, scale: 1.15, duration: 160, ease: 'out(2)' });
            if (++n < 2) { timer = setTimeout(one, 70); return; }
            burst(x, y);
            timer = setTimeout(play, every);
          }, 110);
        })();
      }
    }

    function init() {
      document.querySelectorAll('[data-double-tap-heart]').forEach(function (el) {
        if (el._dbh) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<article>
  <div data-double-tap-heart data-like=".like">
    <img src="photo.jpg" alt="A lake at sunset">
  </div>
  <button type="button" class="like">&#9825; <span data-count>1,204</span> likes</button>
</article>