← Diagrams & maps

Cycle Diagram

Stages sit on a ring while arrows chase round it. The stages pop in one by one as the ring draws itself between them, then a stream of arrowheads keeps flowing clockwise and the highlight steps from stage to stage, lighting the stretch of ring ahead.

Research Design Build Launch Learn

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-cycle-diagram to a container with one child per stage. The first sits at the top and the rest follow clockwise.
  3. Add data-title to put a label in the middle.

Options

Add any of these attributes to the same element.

data-titleText in the middle of the ring.
data-colorColour of the ring, arrows and highlight. Default #a855f7.
data-sizeLargest width of the diagram. Default 340 px.
data-stepHow long each stage stays lit. Default 1400 ms.
data-speedSeconds for the arrows to go once round. Default 14.

The stages are sized by font-size. Short names (one word) keep the ring roomy.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Cycle Diagram: stages sit on a ring while arrows chase round it. The
    // stages pop in one after another as the ring draws itself between them,
    // then a stream of small arrowheads keeps flowing round the ring and the
    // highlight steps from stage to stage, lighting the stretch of ring ahead.
    // Usage:
    //   <div data-cycle-diagram data-title="Product cycle">
    //     <span>Plan</span><span>Build</span><span>Test</span><span>Learn</span>
    //   </div>
    // The first stage sits at the top; the rest follow clockwise.
    // Size it with font-size and data-size.
    // Optional: data-title (text in the middle), data-color (default #a855f7),
    // data-size (largest width in px, default 340), data-step (ms each stage
    // stays lit, default 1400), data-speed (seconds for the arrows to go once
    // round, default 14).
    var DEFAULTS = { color: '#a855f7', size: 340, step: 1400, speed: 14 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.cyd{position:relative;width:100%;aspect-ratio:1/1;--cyd-c:#a855f7}' +
      '.cyd-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.cyd-spin{transform-box:view-box;transform-origin:50% 50%}' +
      '.cyd-slot{position:absolute;transform:translate(-50%,-50%)}' +
      '.cyd-stage{display:block;padding:.5em .9em;border-radius:999px;background:#fff;border:2px solid var(--cyd-c);font-weight:600;white-space:nowrap;line-height:1.2;box-shadow:0 4px 14px rgba(80,30,140,.12);transition:background-color .35s,color .35s,box-shadow .35s}' +
      '.cyd-stage.cyd-on{background:var(--cyd-c);color:#fff;box-shadow:0 6px 20px rgba(120,40,200,.35)}' +
      '.cyd-title{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);max-width:46%;text-align:center;font-weight:800;font-size:1.15em;line-height:1.2;opacity:.85}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

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

    function pt(r, a) { return [50 + Math.cos(a) * r, 50 + Math.sin(a) * r]; }
    function arc(r, a0, a1) {
      var p0 = pt(r, a0), p1 = pt(r, a1);
      return 'M' + p0[0] + ',' + p0[1] + ' A' + r + ',' + r + ' 0 ' + (a1 - a0 > Math.PI ? 1 : 0) + ' 1 ' + p1[0] + ',' + p1[1];
    }

    function setup(el) {
      var d = el.dataset, color = d.color || DEFAULTS.color;
      var s = (el._cyd = { stages: [], active: -1, visible: false, timer: null, ready: false });
      el.classList.add('cyd');
      el.style.maxWidth = (+d.size || DEFAULTS.size) + 'px';
      el.style.setProperty('--cyd-c', color);
      var svg = mk('svg', { class: 'cyd-svg', viewBox: '0 0 100 100', 'aria-hidden': 'true' });
      s.track = mk('g', { fill: 'none', stroke: color, 'stroke-width': 1.4, 'stroke-opacity': 0.35, 'stroke-linecap': 'round' }, svg);
      s.spin = mk('g', { class: 'cyd-spin', fill: 'none', stroke: color, 'stroke-width': 1.1, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, svg);
      s.glow = mk('g', { fill: 'none', stroke: color, 'stroke-width': 2.4, 'stroke-linecap': 'round' }, svg);
      Array.from(el.children).forEach(function (k) {
        var slot = document.createElement('div');
        slot.className = 'cyd-slot';
        var stage = document.createElement('span');
        stage.className = 'cyd-stage';
        while (k.firstChild) stage.appendChild(k.firstChild);
        slot.appendChild(stage);
        el.replaceChild(slot, k);
        s.stages.push({ slot: slot, el: stage, track: mk('path', {}, s.track), glow: mk('path', { opacity: 0 }, s.glow) });
      });
      el.insertBefore(svg, el.firstChild);
      if (d.title) {
        var title = document.createElement('div');
        title.className = 'cyd-title';
        title.textContent = d.title;
        el.appendChild(title);
        s.title = title;
      }
      el.setAttribute('role', 'group');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', (d.title ? d.title + ': ' : 'Cycle: ') + s.stages.map(function (x) { return x.el.textContent.trim(); }).join(', then ') + ', then back to the start');
      layout(el);
      new ResizeObserver(function () { layout(el); }).observe(el);
    }

    // Place stages round the ring and cut the ring where each one sits.
    function layout(el) {
      var s = el._cyd, n = s.stages.length;
      var W = el.clientWidth;
      if (!W || !n) return;
      var maxHalf = 0;
      s.stages.forEach(function (st) { maxHalf = Math.max(maxHalf, st.el.offsetWidth / 2); });
      var R = Math.max(26, 50 - ((maxHalf + 4) / W) * 100);
      var Rpx = (R / 100) * W;
      s.R = R;
      var gaps = s.stages.map(function (st, i) {
        var a = -Math.PI / 2 + (i / n) * Math.PI * 2;
        st.a = a;
        var p = pt(R, a);
        st.slot.style.left = p[0] + '%';
        st.slot.style.top = p[1] + '%';
        // How far along the ring before it leaves this pill.
        var tx = Math.abs(Math.sin(a)), ty = Math.abs(Math.cos(a));
        var hw = st.el.offsetWidth / 2 + 6, hh = st.el.offsetHeight / 2 + 6;
        var dist = Math.min(tx ? hw / tx : Infinity, ty ? hh / ty : Infinity);
        return Math.min(dist / Rpx, Math.PI / n - 0.05);
      });
      s.stages.forEach(function (st, i) {
        var j = (i + 1) % n;
        var a0 = st.a + gaps[i], a1 = s.stages[j].a + (j === 0 ? Math.PI * 2 : 0) - gaps[j];
        var dPath = arc(R, a0, a1);
        st.track.setAttribute('d', dPath);
        st.glow.setAttribute('d', dPath);
      });
      if (s.ready) s.stages.forEach(function (st) { anime.utils.set(anime.svg.createDrawable(st.track), { draw: '0 1' }); });
      // Arrowheads, evenly spaced round the ring, all pointing clockwise.
      s.spin.textContent = '';
      var count = Math.max(12, n * 4);
      for (var c = 0; c < count; c++) {
        var a = (c / count) * Math.PI * 2, p = pt(R, a), deg = (a * 180) / Math.PI + 90;
        mk('path', { d: 'M-1.1,-1.5 L0.6,0 L-1.1,1.5', transform: 'translate(' + p[0] + ',' + p[1] + ') rotate(' + deg + ')' }, s.spin);
      }
    }

    function light(el, i) {
      var s = el._cyd;
      var prev = s.stages[s.active];
      if (prev) {
        prev.el.classList.remove('cyd-on');
        anime.animate(prev.glow, { opacity: 0, duration: 500, ease: 'out(2)' });
      }
      s.active = i;
      var st = s.stages[i];
      st.el.classList.add('cyd-on');
      anime.animate(st.el, { scale: [1, 1.12, 1], duration: 500, ease: 'out(2)' });
      var step = +el.dataset.step || DEFAULTS.step;
      anime.utils.set(st.glow, { opacity: 1 });
      anime.animate(anime.svg.createDrawable(st.glow), { draw: ['0 0', '0 1'], duration: step * 0.85, ease: 'inOut(2)' });
    }

    function tick(el) {
      var s = el._cyd;
      if (!s.visible) return;
      light(el, (s.active + 1) % s.stages.length);
      s.timer = setTimeout(function () { tick(el); }, +el.dataset.step || DEFAULTS.step);
    }

    function intro(el) {
      var s = el._cyd, n = s.stages.length;
      var gap = Math.min(260, 1400 / n);
      if (s.title) anime.animate(s.title, { opacity: [0, 0.85], duration: 800, ease: 'out(2)' });
      s.stages.forEach(function (st, i) {
        anime.animate(st.el, { opacity: [0, 1], scale: [0.4, 1], duration: 500, delay: i * gap, ease: 'outBack(2)' });
        anime.animate(anime.svg.createDrawable(st.track), { draw: ['0 0', '0 1'], duration: gap + 120, delay: i * gap + 200, ease: 'inOut(2)' });
      });
      var end = n * gap + 300;
      anime.animate(s.spin, { opacity: [0, 1], duration: 600, delay: end - 200 });
      s.spinner = anime.animate(s.spin, { rotate: [0, 360], duration: (+el.dataset.speed || DEFAULTS.speed) * 1000, ease: 'linear', loop: true });
      s.timer = setTimeout(function () { s.ready = true; tick(el); }, end);
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._cyd;
          s.visible = e.isIntersecting;
          if (!s.visible) {
            clearTimeout(s.timer);
            if (s.spinner) s.spinner.pause();
            return;
          }
          if (!s.introduced) { s.introduced = true; intro(el); return; }
          if (s.spinner) s.spinner.resume();
          if (s.ready) { clearTimeout(s.timer); tick(el); }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-cycle-diagram]').forEach(function (el) {
        if (el._cyd) return;
        setup(el);
        var s = el._cyd;
        if (still) { s.stages[0].el.classList.add('cyd-on'); return; }
        s.stages.forEach(function (st) {
          anime.utils.set(st.el, { opacity: 0 });
          anime.utils.set(anime.svg.createDrawable(st.track), { draw: '0 0' });
        });
        anime.utils.set(s.spin, { opacity: 0 });
        if (s.title) anime.utils.set(s.title, { opacity: 0 });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-cycle-diagram data-title="Product cycle">
  <span>Plan</span>
  <span>Build</span>
  <span>Test</span>
  <span>Learn</span>
</div>