Cupid's Arrow
A heart beats gently until an arrow streaks in and hits it. The heart swells and pops into a shower of little hearts, the arrow flies on, and a new heart bounces back into place.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-cupids-arrow>where the heart should go. The arrow is fired when it scrolls into view.
Options
Add any of these attributes to the same element.
data-loop | Fire again every few seconds. |
data-color | Heart colour. Default #ef4466. |
data-pieces | Little hearts in the pop. Default 16. |
data-delay | How long the heart beats before the arrow arrives. Default 1400 ms. |
It is 8:5 with a transparent background by default; set your own height or background on the element. With reduced motion only the heart is shown.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Cupid's Arrow: a heart beats gently until an arrow streaks in and hits
// it. The heart swells and pops into a shower of little hearts, the arrow
// flies on, and a new heart bounces back into place. Drawn as SVG that
// fills the element.
// Usage: <div data-cupids-arrow></div>
// It is 8:5 by default with a transparent background; give it your own
// height or background to change that.
// Optional: data-loop (shoot again every few seconds), data-color (heart
// colour, default #ef4466), data-pieces (little hearts in the pop,
// default 16), data-delay (ms the heart beats before the arrow comes,
// default 1400).
var DEFAULTS = { color: '#ef4466', pieces: 16, delay: 1400 };
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var HEART = 'M0 30C-40 5 -38 -26 -18 -28C-8 -29 -2 -22 0 -15C2 -22 8 -29 18 -28C38 -26 40 5 0 30Z';
var HX = 240, HY = 122; // where the heart sits
// The same heart, small, for the pieces of the pop.
var MINI = HEART.replace(/-?\d+(\.\d+)?/g, function (v) { return (v * 0.22).toFixed(2); });
var css =
'[data-cupids-arrow]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
'.cpa-svg{position:absolute;inset:0;width:100%;height:100%;display:block}' +
'.cpa-heart,.cpa-piece,.cpa-ring{transform-box:fill-box;transform-origin:50% 50%}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function build(el) {
var d = el.dataset;
var n = ++uid;
var loop = d.loop !== undefined && d.loop !== 'false';
var color = d.color || DEFAULTS.color;
var count = Math.max(0, d.pieces !== undefined ? +d.pieces : DEFAULTS.pieces);
var wait = d.delay !== undefined ? +d.delay : DEFAULTS.delay;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'An arrow flies into a heart, which pops into little hearts');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var g = 'cpa' + n;
var pieces = '';
var tints = [color, '#f472b6', '#fb7185', '#fda4af', '#f9a8d4'];
for (var i = 0; i < count; i++) {
pieces += '<g transform="translate(' + HX + ' ' + HY + ')"><path class="cpa-piece" d="' + MINI + '" fill="' + tints[i % tints.length] + '" opacity="0"/></g>';
}
var sparks = '';
for (var k = 0; k < 10; k++) {
sparks += '<g transform="translate(' + HX + ' ' + HY + ') rotate(' + (k * 36) + ')"><path class="cpa-spark" d="M0 -40 L0 -52" stroke="#fde68a" stroke-width="2.4" stroke-linecap="round" opacity="0"/></g>';
}
el.innerHTML =
'<svg class="cpa-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs>' +
'<radialGradient id="' + g + '-shine" cx=".35" cy=".3" r=".75"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/></radialGradient>' +
'<radialGradient id="' + g + '-glow"><stop offset="0" stop-color="' + color + '" stop-opacity=".35"/><stop offset="1" stop-color="' + color + '" stop-opacity="0"/></radialGradient>' +
'<linearGradient id="' + g + '-streak" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity=".7"/></linearGradient>' +
'</defs>' +
'<circle class="cpa-glow" cx="' + HX + '" cy="' + HY + '" r="80" fill="url(#' + g + '-glow)"/>' +
'<circle class="cpa-ring" cx="' + HX + '" cy="' + HY + '" r="30" fill="none" stroke="' + color + '" stroke-width="3" opacity="0"/>' +
sparks +
'<g transform="translate(' + HX + ' ' + HY + ')"><g class="cpa-heart"><path d="' + HEART + '" transform="scale(1.15)" fill="' + color + '"/>' +
'<path d="' + HEART + '" transform="scale(1.15)" fill="url(#' + g + '-shine)"/></g></g>' +
pieces +
// The arrow, pointing right, its tip at (0, 0).
'<g class="cpa-arrow" transform="translate(-400 ' + HY + ')">' +
'<rect class="cpa-streak" x="-260" y="-1.5" width="150" height="3" rx="1.5" fill="url(#' + g + '-streak)"/>' +
'<path d="M-110 0 L-8 0" stroke="#a16207" stroke-width="3" stroke-linecap="round"/>' +
'<path d="M0 0 L-14 -7 L-10 0 L-14 7 Z" fill="#facc15" stroke="#ca8a04" stroke-width="1" stroke-linejoin="round"/>' +
'<path d="M-104 0 L-118 -10 L-126 -10 L-114 0 Z M-104 0 L-118 10 L-126 10 L-114 0 Z M-92 0 L-106 -10 L-114 -10 L-102 0 Z M-92 0 L-106 10 L-114 10 L-102 0 Z" fill="#f9a8d4"/>' +
'</g>' +
'</svg>';
var svg = el.firstChild;
var heart = svg.querySelector('.cpa-heart');
var arrow = svg.querySelector('.cpa-arrow');
var streak = svg.querySelector('.cpa-streak');
var ring = svg.querySelector('.cpa-ring');
var glow = svg.querySelector('.cpa-glow');
var bits = svg.querySelectorAll('.cpa-piece');
var sparkEls = svg.querySelectorAll('.cpa-spark');
if (reduced()) return; // just the heart
// Gentle heartbeat while waiting.
var beat = null;
function startBeat() {
beat = anime.animate(heart, { scale: [1, 1.09, 1, 1.05, 1], duration: 1100, ease: 'inOutSine', loop: true });
}
function stopBeat() { if (beat) { beat.cancel(); beat = null; } }
var arrowX = { x: -400 };
function placeArrow() { arrow.setAttribute('transform', 'translate(' + arrowX.x.toFixed(1) + ' ' + HY + ')'); }
// One shot: wait, fly in, pop, fly out, heart returns.
function shot() {
var tl = anime.createTimeline({
onComplete: function () {
if (!loop) startBeat();
else if (visible) shot();
else pending = true;
}
});
tl.add({ duration: wait, onBegin: startBeat })
.add(arrowX, { x: [-400, HX - 26], duration: 520, ease: 'in(1.4)', onUpdate: placeArrow,
onBegin: function () { streak.setAttribute('opacity', '1'); } })
.add(heart, { scale: [1, 1.32], duration: 110, ease: 'out(2)', onBegin: stopBeat })
.add(heart, { scale: 0, duration: 140, ease: 'in(2)' })
.add(bits, {
x: function () { return anime.utils.random(-130, 130); },
y: function () { return anime.utils.random(-120, 90); },
rotate: function () { return anime.utils.random(-120, 120); },
scale: [{ from: 0.4, to: 1.2, duration: 300 }, { to: 0.6, duration: 700 }],
opacity: [{ from: 1, to: 1, duration: 500 }, { to: 0, duration: 500 }],
duration: 1000,
ease: 'out(3)'
}, '<<')
.add(ring, { scale: [0.6, 3.4], opacity: [0.9, 0], strokeWidth: [4, 0.5], duration: 650, ease: 'out(3)' }, '<<')
.add(sparkEls, { opacity: [1, 0], translateY: [0, -26], duration: 500, ease: 'out(2)' }, '<<')
.add(glow, { opacity: [1, 0.2], duration: 400 }, '<<')
.add(arrowX, { x: HX + 520, duration: 520, ease: 'out(1.2)', onUpdate: placeArrow,
onComplete: function () { streak.setAttribute('opacity', '0'); } }, '<<')
.add(heart, { scale: [0, 1], duration: 900, ease: 'outElastic(1, .5)' }, '+=350')
.add(glow, { opacity: 1, duration: 600 }, '<<');
}
var visible = false, started = false, pending = false;
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !started) { started = true; shot(); }
else if (visible && pending) { pending = false; shot(); }
}, { threshold: 0.25 }).observe(el);
}
function init() {
document.querySelectorAll('[data-cupids-arrow]').forEach(function (el) {
if (el._cpa) return; // idempotent
el._cpa = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-cupids-arrow></div>
<!-- looping, on a soft pink panel: -->
<div data-cupids-arrow data-loop style="background: #fff1f2"></div>