← 3D & depth

Tesseract

A four-dimensional hypercube turns through the fourth dimension, so its inner cube swells out to become the outer one while the outer one shrinks inside: it seems to turn itself inside out, forever. Edges glow brighter the nearer they are in that fourth direction. Drag to turn it in 3D too.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-tesseract to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-colorColour of the edges nearest in the fourth dimension. Default #f0abfc.
data-farColour of the farthest edges. Default #7c3aed.
data-speedHow fast it turns. Default 1.
data-sizeSize, as a share of the box’s shorter side. Default 0.8.
data-drag="false"Don’t let people turn it by dragging.

Its 16 corners and 32 edges are turned in four dimensions, projected into 3D, then into 2D. It’s 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 () {
    // Tesseract: a four-dimensional hypercube turning through the fourth
    // dimension, so its inner cube swells out to become the outer one while
    // the outer shrinks inside: it seems to turn itself inside out. Drawn as
    // glowing edges on a canvas; drag to turn the whole thing in 3D.
    // Usage: <div data-tesseract style="height: 360px; background: #0f0720"></div>
    // Optional: data-color (edges near you in the fourth dimension, default
    // #f0abfc), data-far (edges far away in it, default #7c3aed), data-speed
    // (default 1), data-size (share of the box's shorter side, default 0.8),
    // data-drag="false".
    var DEFAULTS = { color: '#f0abfc', far: '#7c3aed', speed: 1, size: 0.8 };

    var css =
      '[data-tesseract]{position:relative;overflow:hidden;touch-action:pan-y}' +
      '.tsr-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function rgb(hex) {
      hex = hex.replace('#', '');
      if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
      var n = parseInt(hex, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }

    // The 16 corners (every mix of ±1 in x, y, z, w) and the 32 edges
    // between corners that differ in one coordinate.
    var VERTS = [], EDGES = [];
    for (var i = 0; i < 16; i++) VERTS.push([i & 1 ? 1 : -1, i & 2 ? 1 : -1, i & 4 ? 1 : -1, i & 8 ? 1 : -1]);
    for (var a = 0; a < 16; a++) for (var b = 0; b < 4; b++) if (!(a & (1 << b))) EDGES.push([a, a | (1 << b)]);

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'tsr-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var near = rgb(d.color || DEFAULTS.color), far = rgb(d.far || DEFAULTS.far);
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var share = +d.size || DEFAULTS.size;
      var w = 0, h = 0, dpr = 1;
      var s = { a: 0.35, yaw: 0.5, pitch: -0.35, vyaw: 0, dragging: false };

      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 project() {
        var a = s.a, ca = Math.cos(a), sa = Math.sin(a);
        var cb = Math.cos(a * 0.62), sb = Math.sin(a * 0.62);
        var cy = Math.cos(s.yaw), sy = Math.sin(s.yaw), cp = Math.cos(s.pitch), sp = Math.sin(s.pitch);
        var unit = Math.min(w, h) * share / 5.6;
        return VERTS.map(function (v) {
          // Turn in the x–w and z–w planes: the fourth-dimensional part.
          var x = v[0] * ca - v[3] * sa, w4 = v[0] * sa + v[3] * ca;
          var z = v[2] * cb - w4 * sb;
          w4 = v[2] * sb + w4 * cb;
          var y = v[1];
          // Into 3D: nearer in w looks bigger.
          var k = 2.6 / (2.6 - w4 * 0.9);
          x *= k; y *= k; z *= k;
          // An ordinary 3D turn for the view.
          var x2 = x * cy + z * sy, z2 = -x * sy + z * cy;
          var y2 = y * cp - z2 * sp, z3 = y * sp + z2 * cp;
          // Into 2D with perspective.
          var p = 7 / (7 - z3);
          return { x: w / 2 + x2 * p * unit, y: h / 2 + y2 * p * unit, z: z3, w: w4, p: p };
        });
      }

      function mix(t, alpha) {
        var c = [0, 1, 2].map(function (i) { return Math.round(far[i] + (near[i] - far[i]) * t); });
        return 'rgba(' + c.join(',') + ',' + alpha.toFixed(3) + ')';
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var P = project();
        var list = EDGES.map(function (e) {
          var p = P[e[0]], q = P[e[1]];
          return { p: p, q: q, z: (p.z + q.z) / 2, w: (p.w + q.w) / 2 };
        }).sort(function (m, n) { return m.z - n.z; });
        ctx.lineCap = 'round';
        ctx.globalCompositeOperation = 'lighter';
        var base = Math.max(1.2, Math.min(w, h) / 160);
        list.forEach(function (e) {
          var t = Math.max(0, Math.min(1, (e.w + 1.4) / 2.8));
          var depth = Math.max(0.25, Math.min(1, 0.6 + e.z * 0.18));
          // A wide faint stroke for the glow, then the line itself.
          ctx.strokeStyle = mix(t, 0.16 * depth);
          ctx.lineWidth = base * 4.5;
          ctx.beginPath(); ctx.moveTo(e.p.x, e.p.y); ctx.lineTo(e.q.x, e.q.y); ctx.stroke();
          ctx.strokeStyle = mix(t, 0.85 * depth);
          ctx.lineWidth = base * (0.7 + t * 0.6);
          ctx.stroke();
        });
        P.forEach(function (v) {
          var t = Math.max(0, Math.min(1, (v.w + 1.4) / 2.8));
          ctx.fillStyle = mix(t, 0.95);
          ctx.beginPath();
          ctx.arc(v.x, v.y, base * (1 + t * 1.3) * v.p, 0, Math.PI * 2);
          ctx.fill();
        });
        ctx.globalCompositeOperation = 'source-over';
      }

      resize();
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);

      if (d.drag !== 'false') {
        el.style.cursor = 'grab';
        var lx = 0, ly = 0, lt = 0;
        el.addEventListener('pointerdown', function (e) {
          s.dragging = true;
          lx = e.clientX; ly = e.clientY; 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.01, dy = (e.clientY - ly) * 0.01;
          s.yaw += dx;
          s.pitch = Math.max(-1.3, Math.min(1.3, s.pitch - dy));
          s.vyaw = dx / Math.max(8, now - lt) * 1000;
          lx = e.clientX; ly = e.clientY; lt = now;
          draw();
        });
        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.a += dt * 0.55 * speed;
          if (!s.dragging) {
            s.vyaw += (0.12 * speed - s.vyaw) * Math.min(1, dt * 1.2);
            s.yaw += s.vyaw * dt;
          }
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-tesseract]').forEach(function (el) {
        if (el._tsr) return;
        el._tsr = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-tesseract style="height: 360px; background: #0f0720"></div>