← Loaders

Cell Split

A round cell swells, pinches in the middle and splits in two, like a cell dividing under a microscope, then the halves drift back together and merge into one again. The neck between them stretches like liquid, and the nucleus divides with them. Each split turns a quarter turn from the last. 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-cell-split> where the loader should appear.

Options

Add any of these attributes to the same element.

data-speedTime for one split and merge. Default 1800 ms.
data-turnHow far the split turns each time, in degrees. Default 90; 0 splits the same way every time.
data-nucleus="false"Plain cells, without a nucleus.

It’s 2.4em across. It only moves while it’s on screen. With reduced motion it rests as two cells just pulled apart.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Cell Split: a round cell swells, pinches in the middle and splits into
    // two, like a cell dividing, then the two drift back together and merge
    // into one again. A gooey filter keeps the neck between them liquid, and
    // each cell's nucleus divides with it. Each split turns a quarter turn
    // from the last.
    // Sized with font-size (2.4em across), coloured with color.
    // It only moves while it's on screen.
    // Usage: <div data-cell-split aria-label="Loading"></div>
    // Optional: data-speed (ms for one split and merge, default 1800),
    // data-turn (degrees the split turns each time, default 90),
    // data-nucleus="false" (plain cells, no nucleus).
    var DEFAULTS = { speed: 1800, turn: 90 };
    var R = 10, R2 = 7.6, D = 11.5; // whole cell, each half, how far each half travels
    var uid = 0;

    var css =
      '.csp{display:inline-block;width:2.4em;height:2.4em;vertical-align:middle}' +
      '.csp svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.csp-cell{fill:currentColor}' +
      '.csp-nuc{fill:#fff;opacity:.4}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var NS = 'http://www.w3.org/2000/svg';

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function make(parent, tag, attrs) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      parent.appendChild(n);
      return n;
    }

    function inOut(v) { return v < 0.5 ? 4 * v * v * v : 1 - Math.pow(-2 * v + 2, 3) / 2; }

    function build(el) {
      var d = el.dataset;
      el.classList.add('csp');
      if (!el.getAttribute('role')) el.setAttribute('role', 'progressbar');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Loading');
      el.textContent = '';

      var id = 'csp-goo-' + (++uid);
      var svg = make(el, 'svg', { viewBox: '-25 -25 50 50', 'aria-hidden': 'true' });
      var filter = make(make(svg, 'defs', {}), 'filter', { id: id, x: '-60%', y: '-60%', width: '220%', height: '220%' });
      make(filter, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 2.6, result: 'b' });
      make(filter, 'feColorMatrix', { in: 'b', mode: 'matrix', values: '1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9' });
      var spin = make(svg, 'g', {});
      var goo = make(spin, 'g', { filter: 'url(#' + id + ')' });
      var a = make(goo, 'circle', { class: 'csp-cell', r: R });
      var b = make(goo, 'circle', { class: 'csp-cell', r: R });
      var na = null, nb = null;
      if (d.nucleus !== 'false') {
        na = make(spin, 'circle', { class: 'csp-nuc', r: 3 });
        nb = make(spin, 'circle', { class: 'csp-nuc', r: 3 });
      }

      var turn = d.turn != null && d.turn !== '' && isFinite(+d.turn) ? +d.turn : DEFAULTS.turn;

      // u: 0…1 through one cycle. Swell, split, hold apart, merge back.
      function draw(cycle, u) {
        var sep = 0, swell = 0;
        if (u < 0.15) swell = Math.sin(u / 0.15 * Math.PI / 2);
        else if (u < 0.5) { var s = inOut((u - 0.15) / 0.35); sep = s; swell = 1 - s; }
        else if (u < 0.62) { sep = 1 + Math.sin((u - 0.5) / 0.12 * Math.PI) * 0.06; }
        else { sep = 1 - inOut((u - 0.62) / 0.38); }
        // Stretch along the split axis just before it pinches.
        var r = R + (R2 - R) * Math.min(1, sep * 1.4) + swell * 1.2;
        var x = sep * D + swell * 1.6;
        a.setAttribute('cx', (-x).toFixed(2)); a.setAttribute('r', r.toFixed(2));
        b.setAttribute('cx', x.toFixed(2)); b.setAttribute('r', r.toFixed(2));
        if (na) {
          var nx = Math.min(1, sep * 1.25) * D;
          var nr = 3 - 0.8 * Math.sin(Math.min(1, sep * 2) * Math.PI) - 0.5 * Math.min(1, sep);
          na.setAttribute('cx', (-nx).toFixed(2)); na.setAttribute('r', nr.toFixed(2));
          nb.setAttribute('cx', nx.toFixed(2)); nb.setAttribute('r', nr.toFixed(2));
        }
        // Each cycle splits along a new axis; it changes while the cell is whole, so the jump can't be seen.
        spin.setAttribute('transform', 'rotate(' + ((cycle * turn) % 360) + ')');
      }

      var speed = Math.max(500, +d.speed || DEFAULTS.speed);
      draw(0, 0.56);
      if (reduced()) return null;
      var t = 0, 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(Math.floor(t / speed), (t % speed) / speed);
        },
        onPause: function () { last = 0; },
      });
    }

    function init() {
      document.querySelectorAll('[data-cell-split]').forEach(function (el) {
        if (el._csp) return;
        el._csp = 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-cell-split style="font-size: 24px; color: #6a0dad" aria-label="Loading"></div>