← Seasonal

Interlocking Rings

Two wedding rings slide in from either side and link together, one passing through the other, with a soft flash as they meet. Then glints run round the metal and little sparkles twinkle about them.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-interlocking-rings> where the rings should go. They come together when it scrolls into view.

Options

Add any of these attributes to the same element.

data-loopSlide apart after a while and link up again.
data-colorsThe two ring colours, comma-separated. Default gold #eab308 and platinum #cbd5e1.
data-gem="false"No diamond on the second ring.
data-sparklesHow many sparkles twinkle round them. Default 12.

It is 8:5 with a transparent background by default; set your own height or background on the element. The sparkle pauses when off screen. With reduced motion the rings are shown linked.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Interlocking Rings: two wedding rings slide in from either side and
    // link together, one passing through the other, with a soft clink. Then
    // glints run round the metal and little sparkles twinkle about them. Loop
    // it and they slide apart and come together again. Drawn as SVG that
    // fills the element.
    // Usage: <div data-interlocking-rings></div>
    // It is 8:5 by default with a transparent background.
    // Optional: data-loop (part and link again), data-colors (the two ring
    // colours, comma-separated, default gold #eab308 and platinum #cbd5e1),
    // data-gem="false" (no diamond on the second ring), data-sparkles (how
    // many sparkles, default 12).
    var DEFAULTS = { colors: ['#eab308', '#cbd5e1'], sparkles: 12 };
    var uid = 0;
    var CY = 132, GAP = 31;

    var css =
      '[data-interlocking-rings]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5}' +
      '.ilr-svg{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function f(n) { return (+n).toFixed(2); }

    // Mix a #rrggbb colour towards another by amount 0..1.
    function mix(a, b, amt) {
      function p(h) { h = h.replace('#', ''); if (h.length === 3) h = h.replace(/./g, '$&$&'); return [0, 2, 4].map(function (i) { return parseInt(h.substr(i, 2), 16); }); }
      var x = p(a), y = p(b);
      return 'rgb(' + x.map(function (v, i) { return Math.round(v + (y[i] - v) * amt); }).join(',') + ')';
    }

    function grad(id, c) {
      return '<linearGradient id="' + id + '" x1="0" y1="0" x2="1" y2="1">' +
        '<stop offset="0" stop-color="' + mix(c, '#ffffff', 0.75) + '"/>' +
        '<stop offset=".3" stop-color="' + c + '"/>' +
        '<stop offset=".55" stop-color="' + mix(c, '#ffffff', 0.45) + '"/>' +
        '<stop offset=".8" stop-color="' + mix(c, '#000000', 0.35) + '"/>' +
        '<stop offset="1" stop-color="' + mix(c, '#ffffff', 0.3) + '"/></linearGradient>';
    }

    function ring(id, gradId, c, extra) {
      return '<g id="' + id + '">' +
        '<ellipse rx="50" ry="45" fill="none" stroke="url(#' + gradId + ')" stroke-width="13"/>' +
        '<ellipse rx="43.5" ry="38.5" fill="none" stroke="' + mix(c, '#000000', 0.5) + '" stroke-opacity=".35" stroke-width="1.2"/>' +
        '<ellipse rx="54" ry="49" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="1.6" pathLength="100" stroke-dasharray="20 80" stroke-dashoffset="62"/>' +
        '<ellipse class="ilr-glint" rx="50" ry="45" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" pathLength="100" stroke-dasharray="5 95" opacity="0"/>' +
        (extra || '') + '</g>';
    }

    function build(el) {
      var d = el.dataset;
      var g = 'ilr' + (++uid);
      var loop = d.loop !== undefined && d.loop !== 'false';
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      if (colors.length < 2) colors.push(colors[0]);
      var gem = d.gem !== 'false';
      var count = Math.max(0, Math.min(40, d.sparkles !== undefined ? Math.round(+d.sparkles) : DEFAULTS.sparkles));
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Two rings slide together, link and sparkle');
      if (!el.hasAttribute('role')) el.setAttribute('role', 'img');

      var gemMarkup = gem ?
        '<g transform="translate(0 -45)">' +
        '<path d="M-5 2 L-3 -6 M5 2 L3 -6" stroke="' + mix(colors[1], '#000000', 0.2) + '" stroke-width="2.4" stroke-linecap="round"/>' +
        '<path d="M-10 -10 L-6 -16 L6 -16 L10 -10 L0 2 Z" fill="url(#' + g + '-gem)"/>' +
        '<path d="M-10 -10 L10 -10 M-6 -16 L-3 -10 L0 2 L3 -10 L6 -16 M-3 -10 L0 -16 L3 -10" fill="none" stroke="#fff" stroke-opacity=".7" stroke-width=".7"/>' +
        '<circle class="ilr-gemglint" cx="-3" cy="-12" r="2" fill="#fff" opacity=".8"/>' +
        '</g>' : '';

      var stars = '';
      for (var i = 0; i < count; i++) stars += '<path class="ilr-star" d="M0 -8 C1 -2 2 -1 8 0 C2 1 1 2 0 8 C-1 2 -2 1 -8 0 C-2 -1 -1 -2 0 -8 Z" fill="#fff" opacity="0"/>';

      el.innerHTML =
        '<svg class="ilr-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
        '<defs>' + grad(g + '-a', colors[0]) + grad(g + '-b', colors[1]) +
        '<linearGradient id="' + g + '-gem" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset=".5" stop-color="#dbeafe"/><stop offset="1" stop-color="#93c5fd"/></linearGradient>' +
        '<radialGradient id="' + g + '-glow"><stop offset="0" stop-color="#fff7d6" stop-opacity=".95"/><stop offset=".4" stop-color="#fde68a" stop-opacity=".35"/><stop offset="1" stop-color="#fde68a" stop-opacity="0"/></radialGradient>' +
        '<clipPath id="' + g + '-front"><rect class="ilr-clip" x="-2000" y="' + CY + '" width="4400" height="2000"/></clipPath>' +
        '</defs>' +
        '<circle class="ilr-glow" cx="200" cy="' + CY + '" r="120" fill="url(#' + g + '-glow)" opacity="0"/>' +
        '<ellipse class="ilr-shadow" cx="200" cy="' + (CY + 82) + '" rx="80" ry="7" fill="#000" opacity="0"/>' +
        ring(g + '-ra', g + '-a', colors[0]) +
        ring(g + '-rb', g + '-b', colors[1], gemMarkup) +
        '<g clip-path="url(#' + g + '-front)"><use href="#' + g + '-ra"/></g>' +
        '<g class="ilr-stars">' + stars + '</g>' +
        '</svg>';

      var svg = el.firstChild;
      var ra = svg.getElementById(g + '-ra'), rb = svg.getElementById(g + '-rb');
      var glints = svg.querySelectorAll('.ilr-glint');
      var gemGlint = svg.querySelector('.ilr-gemglint');
      var clip = svg.querySelector('.ilr-clip');
      var glow = svg.querySelector('.ilr-glow');
      var shadow = svg.querySelector('.ilr-shadow');
      var A = { x: 200 - GAP, rot: -14, o: 1, s: 1 };
      var B = { x: 200 + GAP, rot: 14, o: 1, s: 1 };
      var S = { sp: 0, glow: 0, bob: 0 };

      // Sparkles sit in a loose ring round the pair.
      var STARS = [];
      svg.querySelectorAll('.ilr-star').forEach(function (s, i) {
        var a = (i / Math.max(1, count)) * Math.PI * 2 + Math.random() * 0.5;
        var r = 78 + Math.random() * 40;
        STARS.push({ el: s, x: 200 + Math.cos(a) * r * 1.25, y: CY + Math.sin(a) * r * 0.75, sz: 0.5 + Math.random() * 0.7, ph: Math.random() * 6.3, rate: 1.6 + Math.random() * 1.8 });
      });

      var t = 0;
      function render(dt) {
        t += dt || 0;
        var s = t / 1000;
        var y = CY + Math.sin(s * 1.6) * 3 * S.bob;
        ra.setAttribute('transform', 'translate(' + f(A.x) + ' ' + f(y) + ') rotate(' + f(A.rot) + ') scale(' + f(A.s) + ')');
        rb.setAttribute('transform', 'translate(' + f(B.x) + ' ' + f(y) + ') rotate(' + f(B.rot) + ') scale(' + f(B.s) + ')');
        ra.setAttribute('opacity', f(A.o));
        rb.setAttribute('opacity', f(B.o));
        clip.setAttribute('y', f(y));
        glow.setAttribute('opacity', f(S.glow));
        shadow.setAttribute('opacity', f(0.12 * Math.min(A.o, B.o)));
        shadow.setAttribute('rx', f(Math.max(40, (B.x - A.x) / 2 + 55)));
        shadow.setAttribute('cx', f((A.x + B.x) / 2));
        glints.forEach(function (gl, i) {
          gl.setAttribute('stroke-dashoffset', f(-((s * 22 + i * 50) % 100)));
          gl.setAttribute('opacity', f(0.85 * S.sp));
        });
        if (gemGlint) gemGlint.setAttribute('opacity', f(0.3 + 0.7 * Math.pow(Math.max(0, Math.sin(s * 3.1)), 4)));
        STARS.forEach(function (st) {
          var k = S.sp * Math.pow(Math.max(0, Math.sin(s * st.rate + st.ph)), 3);
          st.el.setAttribute('transform', 'translate(' + f(st.x) + ' ' + f(st.y) + ') rotate(' + f(s * 40 + st.ph * 30) + ') scale(' + f(k * st.sz) + ')');
          st.el.setAttribute('opacity', f(k));
        });
      }

      if (reduced()) { S.sp = 0.6; render(0); return; }

      var tl = null, visible = false, pending = false;
      function play() {
        if (tl) tl.pause();
        A.x = 200 - GAP - 190; A.o = 0; A.rot = -60; A.s = 1;
        B.x = 200 + GAP + 190; B.o = 0; B.rot = 60; B.s = 1;
        S.sp = 0; S.glow = 0; S.bob = 0;
        tl = anime.createTimeline({
          onComplete: function () {
            tl = null;
            if (!loop) return;
            if (visible) play(); else pending = true;
          }
        });
        tl.add(A, { x: 200 - GAP, rot: -14, o: { to: 1, duration: 400 }, duration: 1300, ease: 'out(4)' }, 250)
          .add(B, { x: 200 + GAP, rot: 14, o: { to: 1, duration: 400 }, duration: 1300, ease: 'out(4)' }, 350)
          .add([A, B], { s: [{ to: 1.07, duration: 140, ease: 'out(2)' }, { to: 1, duration: 700, ease: 'outElastic(1, .4)' }] }, 1250)
          .add(S, { glow: [{ to: 0.9, duration: 160, ease: 'out(2)' }, { to: 0.25, duration: 1400, ease: 'out(2)' }] }, 1250)
          .add(S, { sp: [0, 1], duration: 900, ease: 'out(2)' }, 1300)
          .add(S, { bob: [0, 1], duration: 1200, ease: 'inOut(2)' }, 1500);
        if (loop) {
          tl.add(S, { sp: 0, glow: 0, bob: 0, duration: 600, ease: 'in(2)' }, 6200)
            .add(A, { x: 200 - GAP - 190, rot: -60, o: { to: 0, duration: 500, delay: 400 }, duration: 900, ease: 'in(3)' }, 6400)
            .add(B, { x: 200 + GAP + 190, rot: 60, o: { to: 0, duration: 500, delay: 400 }, duration: 900, ease: 'in(3)' }, 6400)
            .add({ duration: 500 });
        }
      }

      play();
      tl.pause();
      render(0);
      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) : 16;
          last = now;
          render(dt);
        }
      });
      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) { last = 0; timer.play(); }
        else { timer.pause(); if (tl) tl.pause(); }
        if (visible && !started) { started = true; play(); }
        else if (visible && pending) { pending = false; play(); }
        else if (visible && tl) tl.play();
      }, { threshold: 0.25 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-interlocking-rings]').forEach(function (el) {
        if (el._ilr) return;   // idempotent
        el._ilr = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-interlocking-rings></div>

<!-- two rose-gold bands, no diamond, again and again: -->
<div data-interlocking-rings data-loop data-colors="#e8a87c,#e8a87c" data-gem="false"></div>