← Loaders

DNA Helix

Two strands of dots twist round each other. Each pair swings up and down a little behind its neighbour, and the dots grow as they come to the front and shrink and fade as they pass behind, so the row reads as a turning double helix. Add rungs for the base pairs. Size it with font-size and colour it with color.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-dna-helix> where the loader should appear.

Options

Add any of these attributes to the same element.

data-dotsDots in each strand, 4 to 24. Default 10.
data-speedTime for one turn. Default 2000 ms.
data-twistHow many turns along the row. Default 1.
data-colorsTwo comma-separated colours, one per strand. Default color for both.
data-rungsDraw a faint line between each pair of dots.

It only moves while it’s on screen. With reduced motion the helix sits still, mid-twist.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // DNA Helix: two strands of dots twist round each other. Each dot swings up
    // and down out of step with its neighbours, growing as it comes to the
    // front and shrinking and fading as it goes behind, so the row reads as a
    // turning double helix. Sized with font-size, coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-dna-helix aria-label="Loading"></div>
    // Optional: data-dots (dots per strand, 4 to 24, default 10), data-speed
    // (ms for one turn, default 2000), data-twist (turns along the row,
    // default 1), data-colors (two comma-separated colours, one per strand),
    // data-rungs (draw a faint line between each pair, like base pairs).
    var DEFAULTS = { dots: 10, speed: 2000, twist: 1 };
    var GAP = 0.5, DOT = 0.32, AMP = 0.7; // em

    var css =
      '.dnh{position:relative;display:inline-block;height:' + (AMP * 2 + DOT) + 'em;vertical-align:middle}' +
      '.dnh-d,.dnh-r{position:absolute;top:50%;left:0;will-change:transform}' +
      '.dnh-d{width:' + DOT + 'em;height:' + DOT + 'em;margin:' + (-DOT / 2) + 'em 0 0 ' + (-DOT / 2) + 'em;border-radius:50%;background:currentColor}' +
      '.dnh-r{width:0.06em;height:2em;margin:-1em 0 0 -0.03em;background:currentColor;opacity:.18;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 span(cls, parent) {
      var s = document.createElement('span');
      s.className = cls;
      parent.appendChild(s);
      return s;
    }

    function build(el) {
      var d = el.dataset;
      var n = Math.max(4, Math.min(24, Math.round(+d.dots) || DEFAULTS.dots));
      var twist = d.twist != null && !isNaN(+d.twist) ? +d.twist : DEFAULTS.twist;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [];
      var rungs = d.rungs !== undefined && d.rungs !== 'false';
      el.classList.add('dnh');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';
      el.style.width = ((n - 1) * GAP + DOT) + 'em';
      var cols = [];
      for (var i = 0; i < n; i++) {
        var x = (DOT / 2 + i * GAP) + 'em';
        var col = { x: x, phase: i / Math.max(1, n - 1) * twist * Math.PI * 2 };
        if (rungs) { col.r = span('dnh-r', el); col.r.style.left = x; }
        col.a = span('dnh-d', el);
        col.b = span('dnh-d', el);
        col.a.style.left = col.b.style.left = x;
        if (colors[0]) col.a.style.background = colors[0];
        if (colors[1]) col.b.style.background = colors[1];
        col.a.setAttribute('aria-hidden', 'true');
        col.b.setAttribute('aria-hidden', 'true');
        if (col.r) col.r.setAttribute('aria-hidden', 'true');
        cols.push(col);
      }

      function draw(t) {
        cols.forEach(function (c) {
          var s = Math.sin(t + c.phase), z = Math.cos(t + c.phase);
          // z runs from -1 (behind) to 1 (in front); strand b is opposite.
          var fa = 0.55 + 0.45 * (z + 1) / 2, fb = 0.55 + 0.45 * (1 - z) / 2;
          c.a.style.transform = 'translateY(' + (s * AMP).toFixed(3) + 'em) scale(' + fa.toFixed(3) + ')';
          c.b.style.transform = 'translateY(' + (-s * AMP).toFixed(3) + 'em) scale(' + fb.toFixed(3) + ')';
          c.a.style.opacity = (0.35 + 0.65 * (z + 1) / 2).toFixed(3);
          c.b.style.opacity = (0.35 + 0.65 * (1 - z) / 2).toFixed(3);
          c.a.style.zIndex = z > 0 ? 2 : 1;
          c.b.style.zIndex = z > 0 ? 1 : 2;
          if (c.r) c.r.style.transform = 'scaleY(' + (Math.abs(s) * AMP).toFixed(3) + ')';
        });
      }

      draw(0.6);
      if (reduced()) return null;
      var speed = Math.max(300, +d.speed || DEFAULTS.speed);
      var t = 0.6 * speed / (Math.PI * 2), last = 0;
      return anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += last ? Math.min(50, now - last) : 16;
          last = now;
          draw(t / speed * Math.PI * 2);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-dna-helix]').forEach(function (el) {
        if (el._dnh) return;
        el._dnh = true;
        var timer = build(el);
        if (!timer) return;
        new IntersectionObserver(function (entries) {
          entries[0].isIntersecting ? timer.play() : timer.pause();
        }).observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-dna-helix style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>

<!-- two colours, with rungs: -->
<div data-dna-helix data-rungs data-colors="#0ea5e9,#10b981"></div>