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
- Paste the snippet just before
</head>. - Add
data-double-tap-heartto the photo (or its wrapper). - Optionally point
data-likeat your like button, next to the photo. Putdata-counton the number inside it.
Options
Add any of these attributes to the same element.
data-like | A 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-color | The big heart’s colour. Default white. |
data-size | The big heart’s 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. |
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.
<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><article>
<div data-double-tap-heart data-like=".like">
<img src="photo.jpg" alt="A lake at sunset">
</div>
<button type="button" class="like">♡ <span data-count>1,204</span> likes</button>
</article>