Möbius Strip
A ribbon with a half twist in it loops round and joins itself, turning slowly in space while its colours shade round the loop and bands flow along it. A little white bead rides along the surface: watch it go round twice before it’s back where it started, because a Möbius strip has only one side.
How to use it
- Paste the snippet just before
</head>. - Add
data-mobius-stripto a box with a height.
Options
Add any of these attributes to the same element.
data-colors | Colours round the ribbon, separated by commas. Default violet, pink, amber and sky blue. |
data-width | Width of the ribbon, from 0.2 to 1. Default 0.55. |
data-speed | How fast it turns and flows. Default 1. |
data-bead="false" | No travelling bead. |
data-drag="false" | Don’t let people spin it by dragging. |
Drawn on a canvas, paused when off screen. With reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Möbius Strip: a ribbon with a half twist loops round on itself and turns
// slowly in space, its colour shading from one end to the other and bands
// flowing along it. A little bead travels the surface: it takes two laps to
// get back where it started, because the strip has only one side.
// Usage: <div data-mobius-strip style="height: 320px"></div>
// Optional: data-colors (two or more colours along the ribbon, separated by
// commas, default "#7c3aed,#db2777,#f59e0b,#0ea5e9"), data-width (width of the
// ribbon, 0.2 to 1, default 0.55), data-speed (how fast it turns, default 1),
// data-bead="false" (no travelling bead), data-drag="false".
var DEFAULTS = { colors: '#7c3aed,#db2777,#f59e0b,#0ea5e9', width: 0.55, speed: 1 };
var U = 140, V = 5;
var css =
'[data-mobius-strip]{position:relative;overflow:hidden;touch-action:pan-y}' +
'.mbs-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
hex = hex.trim().replace('#', '');
if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
var n = parseInt(hex, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
// A point on the strip: u goes round (0 to 2π), v across (-1 to 1).
function point(u, v, half) {
var r = 1 + v * half * Math.cos(u / 2);
return [r * Math.cos(u), r * Math.sin(u), v * half * Math.sin(u / 2)];
}
function setup(el) {
var d = el.dataset;
var cols = (d.colors || DEFAULTS.colors).split(',').map(rgb);
if (cols.length < 2) cols.push(cols[0]);
var half = Math.max(0.1, Math.min(0.5, (+d.width || DEFAULTS.width) / 2));
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var bead = d.bead !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'mbs-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, dpr = 1;
var s = { yaw: 0.4, t: 0, flow: 0, bead: 0, v: 0, dragging: false };
// The mesh, once: grid points and, per quad, its colour along the loop.
var grid = [], i, j;
for (i = 0; i <= U; i++) {
grid.push([]);
for (j = 0; j <= V; j++) grid[i].push(point(i / U * Math.PI * 2, j / V * 2 - 1, half));
}
function colourAt(f) {
// Round the loop and back to the first colour, so there's no seam.
var n = cols.length, x = ((f % 1) + 1) % 1 * n, k = Math.floor(x), m = x - k;
var a = cols[k], b = cols[(k + 1) % n];
return [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m];
}
var quadCol = [];
for (i = 0; i < U; i++) quadCol.push(colourAt((i + 0.5) / U));
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var r = Math.min(w * 0.36, h * 0.48);
var ox = w / 2, oy = h / 2;
var cy = Math.cos(s.yaw), sy = Math.sin(s.yaw);
var pitch = 1.05 + Math.sin(s.t * 0.4) * 0.18, cp = Math.cos(pitch), sp = Math.sin(pitch);
// The strip lies in the xy plane; tip it so we look down at it at an angle.
function view(p) {
var x = p[0] * cy - p[1] * sy, y = p[0] * sy + p[1] * cy, z = p[2];
return [x, y * cp + z * sp, -y * sp + z * cp];
}
function scr(p) { var k = 6 / (6 - p[2]); return [ox + p[0] * r * k, oy + p[1] * r * k * 0.98]; }
var P = grid.map(function (row) { return row.map(function (p) { var q = view(p); return { v: q, s: scr(q) }; }); });
var quads = [];
for (i = 0; i < U; i++) for (j = 0; j < V; j++) {
var a = P[i][j], b = P[i + 1][j], c = P[i + 1][j + 1], e = P[i][j + 1];
var ux = b.v[0] - a.v[0], uy = b.v[1] - a.v[1], uz = b.v[2] - a.v[2];
var wx = e.v[0] - a.v[0], wy = e.v[1] - a.v[1], wz = e.v[2] - a.v[2];
var nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx;
var nl = Math.hypot(nx, ny, nz) || 1;
quads.push({ i: i, j: j, pts: [a.s, b.s, c.s, e.s], z: (a.v[2] + b.v[2] + c.v[2] + e.v[2]) / 4, nx: nx / nl, ny: ny / nl, nz: nz / nl });
}
// The travelling bead: two laps to come back, as it's on "the other side" after one.
var beadItem = null;
if (bead) {
var bu = s.bead % (Math.PI * 4), bv = 0.55;
var p0 = point(bu, bv, half), p1 = point(bu + 0.01, bv, half), p2 = point(bu, bv + 0.01, half);
var tu = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]], tv = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
var bn = [tu[1] * tv[2] - tu[2] * tv[1], tu[2] * tv[0] - tu[0] * tv[2], tu[0] * tv[1] - tu[1] * tv[0]];
var bl = Math.hypot(bn[0], bn[1], bn[2]) || 1;
var off = 0.06;
var bp = view([p0[0] + bn[0] / bl * off, p0[1] + bn[1] / bl * off, p0[2] + bn[2] / bl * off]);
beadItem = { bead: true, z: bp[2] + 0.02, s: scr(bp), k: 6 / (6 - bp[2]) };
quads.push(beadItem);
}
quads.sort(function (m, n) { return m.z - n.z; });
ctx.lineJoin = 'round';
ctx.lineWidth = 0.7;
quads.forEach(function (q) {
if (q.bead) {
var br = Math.max(3, r * 0.045) * q.k;
var g = ctx.createRadialGradient(q.s[0] - br * 0.3, q.s[1] - br * 0.3, 0, q.s[0], q.s[1], br);
g.addColorStop(0, '#fff');
g.addColorStop(1, '#e2e8f0');
ctx.fillStyle = g;
ctx.shadowColor = 'rgba(20,0,40,.35)';
ctx.shadowBlur = br;
ctx.beginPath();
ctx.arc(q.s[0], q.s[1], br, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
return;
}
// Two-sided lighting: whichever face we see is lit.
var facing = q.nz < 0 ? -1 : 1;
var lam = Math.max(0, facing * (q.nx * -0.4 + q.ny * -0.5 + q.nz * 0.77));
var k = 0.5 + lam * 0.65;
var band = 0.5 + 0.5 * Math.cos(((q.i / U) * 18 - s.flow) * Math.PI * 2);
k += Math.pow(band, 8) * 0.22;
var edge = (q.j === 0 || q.j === V - 1) ? 0.9 : 1;
var c = quadCol[q.i];
var col = 'rgb(' + c.map(function (x) { var y = x * k * edge; return Math.round(y > 255 ? 255 : y); }).join(',') + ')';
if (k > 1) col = 'rgb(' + c.map(function (x) { return Math.round(Math.min(255, x + (255 - x) * (k - 1) * 0.8)); }).join(',') + ')';
ctx.fillStyle = col;
ctx.strokeStyle = col;
ctx.beginPath();
ctx.moveTo(q.pts[0][0], q.pts[0][1]);
ctx.lineTo(q.pts[1][0], q.pts[1][1]);
ctx.lineTo(q.pts[2][0], q.pts[2][1]);
ctx.lineTo(q.pts[3][0], q.pts[3][1]);
ctx.closePath();
ctx.fill();
ctx.stroke();
});
}
resize();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
if (reduced()) return;
if (d.drag !== 'false') {
el.style.cursor = 'grab';
var lx = 0, lt = 0;
el.addEventListener('pointerdown', function (e) {
s.dragging = true;
s.v = 0;
lx = e.clientX; lt = performance.now();
el.setPointerCapture(e.pointerId);
el.style.cursor = 'grabbing';
});
el.addEventListener('pointermove', function (e) {
if (!s.dragging) return;
var now = performance.now(), dx = (e.clientX - lx) * 0.012;
s.yaw += dx;
s.v = dx / Math.max(8, now - lt) * 1000;
lx = e.clientX; lt = now;
});
var end = function () { s.dragging = false; el.style.cursor = 'grab'; };
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) / 1000 : 0.016;
last = now;
s.t += dt * speed;
s.flow += dt * 0.6 * speed;
s.bead += dt * 0.7 * speed;
if (!s.dragging) {
s.yaw += (0.35 * speed + s.v) * dt;
s.v *= Math.pow(0.05, dt);
if (Math.abs(s.v) < 0.02) s.v = 0;
}
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-mobius-strip]').forEach(function (el) {
if (el._mbs) return;
el._mbs = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-mobius-strip style="height: 320px"></div>
<!-- or in two colours: -->
<div data-mobius-strip data-colors="#0ea5e9,#22c55e" style="height: 320px"></div>