← Seasonal

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

  1. Paste the snippet just before </head>.
  2. 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-loopFire again every few seconds.
data-colorHeart colour. Default #ef4466.
data-piecesLittle hearts in the pop. Default 16.
data-delayHow 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.

1 · Snippet
<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>
2 · Your HTML
<div data-cupids-arrow></div>

<!-- looping, on a soft pink panel: -->
<div data-cupids-arrow data-loop style="background: #fff1f2"></div>