← 3D & depth

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

  1. Paste the snippet just before </head>.
  2. Add data-mobius-strip to a box with a height.

Options

Add any of these attributes to the same element.

data-colorsColours round the ribbon, separated by commas. Default violet, pink, amber and sky blue.
data-widthWidth of the ribbon, from 0.2 to 1. Default 0.55.
data-speedHow 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.

1 · Snippet
<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>
2 · Your HTML
<div data-mobius-strip style="height: 320px"></div>

<!-- or in two colours: -->
<div data-mobius-strip data-colors="#0ea5e9,#22c55e" style="height: 320px"></div>