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
- Paste the snippet just before
</head>. - Add
data-cycle-diagramto a container with one child per stage. The first sits at the top and the rest follow clockwise. - Add
data-titleto put a label in the middle.
Options
Add any of these attributes to the same element.
data-title | Text in the middle of the ring. |
data-color | Colour of the ring, arrows and highlight. Default #a855f7. |
data-size | Largest width of the diagram. Default 340 px. |
data-step | How long each stage stays lit. Default 1400 ms. |
data-speed | Seconds for the arrows to go once round. Default 14. |
The stages are sized by font-size. Short names (one word) keep the ring roomy.
<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><div data-cycle-diagram data-title="Product cycle">
<span>Plan</span>
<span>Build</span>
<span>Test</span>
<span>Learn</span>
</div>