Floating Hearts
Hearts pop up from a corner and float upwards, swaying from side to side and fading, like the likes on a live stream. Tap anywhere inside to send one of your own.
Tap to send love
How to use it
- Paste the snippet just before
</head>. - Add
data-floating-heartsto a section, video frame or card. While it’s in view it sends hearts by itself, and every tap or click sends another from that spot.
Options
Add any of these attributes to the same element.
data-rate | Average time between hearts. Default 450 ms. 0 sends them only when tapped. |
data-from | Which bottom corner they rise from: right (default), left or center. |
data-size | Heart size in px. Default 30. |
data-duration | How long each takes to float up. Default 3000 ms. |
data-colors | Comma-separated colours. |
It’s drawn on a transparent canvas behind your content. With reduced motion a few hearts hang still. From code, animacious.heart(element) sends one, e.g. when a viewer likes the stream.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Floating Hearts: hearts pop up from a corner and float upwards, swaying
// from side to side and fading out, like the likes on a live stream. Tap or
// click anywhere inside to send one from that spot; while it's in view it
// also sends them by itself, now and then in little flurries.
// Usage: <section data-floating-hearts>…</section>
// Optional: data-rate (average ms between hearts, default 450; 0 sends
// them only on tap), data-from ("right", default, "left" or "center"),
// data-size (heart size in px, default 30), data-duration (ms to float up,
// default 3000), data-colors="#ff3b6b,#ff7aa2,…".
// Script use: animacious.heart(element) sends one.
var DEFAULTS = {
rate: 450, size: 30, duration: 3000,
colors: ['#ff3b6b', '#ff7aa2', '#e64980', '#be4bdb', '#ff922b', '#f783ac']
};
var css =
'[data-floating-hearts]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
'.fht-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// A heart on a 64 × 64 box, with a little shine.
function sprite(color) {
var c = document.createElement('canvas');
c.width = c.height = 64;
var g = c.getContext('2d');
g.beginPath();
g.moveTo(32, 58);
g.bezierCurveTo(10, 44, 2, 30, 4, 20);
g.bezierCurveTo(6, 8, 24, 2, 32, 16);
g.bezierCurveTo(40, 2, 58, 8, 60, 20);
g.bezierCurveTo(62, 30, 54, 44, 32, 58);
g.closePath();
g.fillStyle = color.trim();
g.fill();
g.globalCompositeOperation = 'source-atop';
var shade = g.createLinearGradient(0, 4, 0, 60);
shade.addColorStop(0, 'rgba(255,255,255,.18)');
shade.addColorStop(1, 'rgba(0,0,0,.14)');
g.fillStyle = shade;
g.fillRect(0, 0, 64, 64);
g.globalCompositeOperation = 'source-over';
g.fillStyle = 'rgba(255,255,255,.55)';
g.beginPath();
g.ellipse(17, 18, 6, 3.6, -0.7, 0, Math.PI * 2);
g.fill();
return c;
}
// An overshooting pop for the first moment of each heart.
function pop(k) {
if (k >= 1) return 1;
var s = 2.2;
k -= 1;
return 1 + (s + 1) * k * k * k + s * k * k;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'fht-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var rate = d.rate !== undefined ? +d.rate : DEFAULTS.rate;
var from = d.from || 'right';
var size = +d.size || DEFAULTS.size;
var duration = (+d.duration || DEFAULTS.duration) / 1000;
var img = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(sprite);
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1;
var hearts = [], visible = false, next = 0, clock = 0;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
}
function origin() {
var s = size * Math.max(0.7, Math.min(1, h / 360));
var x = from === 'left' ? s * 1.4 : from === 'center' ? w / 2 : w - s * 1.4;
return { x: x, y: h - s * 0.9 };
}
function send(x, y) {
var o = origin();
if (x === undefined) { x = o.x; y = o.y; }
var scale = Math.max(0.7, Math.min(1, h / 360));
hearts.push({
x: x, y: y,
age: 0,
life: duration * R(0.85, 1.15, 2),
rise: Math.min(h * 0.85, Math.max(160, y - 10)) * R(0.8, 1, 2),
sway: R(14, 30) * scale * (Math.random() < 0.5 ? -1 : 1),
freq: R(1.6, 2.8, 2),
drift: R(-24, 12) * scale * (from === 'left' ? -1 : 1),
size: size * scale * R(0.8, 1.15, 2),
img: img[Math.floor(Math.random() * img.length)]
});
wake();
}
function advance(dt) {
clock += dt;
if (rate > 0 && visible && clock >= next) {
send();
// Mostly a steady trickle, sometimes a flurry of quick ones.
next = clock + (Math.random() < 0.15 ? 0.09 : (rate / 1000) * R(0.5, 1.6, 2));
}
for (var i = hearts.length - 1; i >= 0; i--) {
hearts[i].age += dt;
if (hearts[i].age >= hearts[i].life) hearts.splice(i, 1);
}
}
function draw() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (var i = 0; i < hearts.length; i++) {
var p = hearts[i], k = p.age / p.life;
var ease = 1 - Math.pow(1 - k, 1.6); // quick off the mark, easing as it rises
var sway = Math.sin(p.age * p.freq) * p.sway * Math.min(1, k * 3);
var x = p.x + sway + p.drift * k;
var y = p.y - p.rise * ease;
var s = p.size * pop(p.age / 0.28) * (1 - k * 0.15);
var rot = Math.cos(p.age * p.freq) * 0.2 * Math.sign(p.sway);
var c = Math.cos(rot) * s / 64 * dpr, sn = Math.sin(rot) * s / 64 * dpr;
ctx.globalAlpha = k < 0.5 ? 1 : Math.max(0, 1 - (k - 0.5) / 0.5);
ctx.setTransform(c, sn, -sn, c, x * dpr, y * dpr);
ctx.drawImage(p.img, -32, -32);
}
ctx.globalAlpha = 1;
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) / 1000 : 1 / 60;
last = now;
advance(dt);
draw();
if (!hearts.length && (!visible || rate <= 0)) timer.pause();
}
});
function wake() { if (visible) { last = 0; timer.play(); } }
resize();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
if (reduced()) {
// A still frame: a few hearts on their way up.
for (var i = 0; i < 5; i++) {
send();
hearts[i].age = hearts[i].life * (0.12 + i * 0.12);
}
draw();
return;
}
el.addEventListener('pointerdown', function (e) {
var r = el.getBoundingClientRect();
send(e.clientX - r.left, e.clientY - r.top);
});
el._fhtSend = function () { send(); };
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) wake(); else timer.pause();
}, { threshold: 0.2 }).observe(el);
}
window.animacious = window.animacious || {};
window.animacious.heart = function (el) { if (el && el._fhtSend) el._fhtSend(); };
function init() {
document.querySelectorAll('[data-floating-hearts]').forEach(function (el) {
if (el._fht) return;
el._fht = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="live-video" data-floating-hearts>
<video src="stream.mp4" autoplay muted></video>
</div>
<!-- only when tapped, from the bottom centre: -->
<div data-floating-hearts data-rate="0" data-from="center">…</div>