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
- Paste the snippet just before
</head>. - Add an empty
<div data-dna-helix>where the loader should appear.
Options
Add any of these attributes to the same element.
data-dots | Dots in each strand, 4 to 24. Default 10. |
data-speed | Time for one turn. Default 2000 ms. |
data-twist | How many turns along the row. Default 1. |
data-colors | Two comma-separated colours, one per strand. Default color for both. |
data-rungs | Draw 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.
<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><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>