← 3D & depth

Torus Knot

A glossy tube winds round and through itself in a trefoil knot, turning and tumbling slowly in space. A bright highlight slides along its curves as they catch the light, and its colour shades from violet through pink to amber and back. Drag or flick to spin it, or try other windings for fancier knots.

How to use it

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

Options

Add any of these attributes to the same element.

data-pHow many times it winds round the ring. Default 2.
data-qHow many times it winds through the hole. Default 3. With data-p, whole numbers with no common factor: 2 and 3 make a trefoil, 3 and 4 or 2 and 5 fancier knots.
data-colorsColours along the tube, separated by commas. Default #7c3aed,#db2777,#f59e0b.
data-thicknessThickness of the tube, from 0.05 to 0.3. Default 0.13.
data-speedHow fast it turns. Default 1.
data-drag="false"Don’t let people spin it by dragging.

Drawn on a canvas with shading and a shiny highlight, 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 () {
    // Torus Knot: a glossy tube winds round and through itself in a knot and
    // turns slowly in space, with a bright highlight sliding along its curves
    // and its colour shading from one end of the loop to the other.
    // Usage: <div data-torus-knot style="height: 320px"></div>
    // Optional: data-p and data-q (how many times it winds round the ring and
    // through the hole; whole numbers with no common factor, default 2 and 3,
    // the trefoil), data-colors (colours along the tube, separated by commas,
    // default "#7c3aed,#db2777,#f59e0b"), data-thickness (tube thickness, 0.05 to
    // 0.3, default 0.13), data-speed (how fast it turns, default 1),
    // data-drag="false".
    var DEFAULTS = { p: 2, q: 3, colors: '#7c3aed,#db2777,#f59e0b', thickness: 0.13, speed: 1 };
    var SIDES = 10;

    var css =
      '[data-torus-knot]{position:relative;overflow:hidden;touch-action:pan-y}' +
      '.tkn-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];
    }
    function sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
    function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
    function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
    function norm(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }

    // Build the tube: rings of points round the knot's curve, with a smoothly
    // carried-along frame so the tube doesn't twist.
    function tube(p, q, rad, segs) {
      function curve(t) {
        var r = (2 + Math.cos(q * t)) / 3;
        return [r * Math.cos(p * t), r * Math.sin(p * t), -Math.sin(q * t) / 3];
      }
      var C = [], T = [], N = [], i;
      for (i = 0; i <= segs; i++) {
        var t = i / segs * Math.PI * 2;
        C.push(curve(t));
        T.push(norm(sub(curve(t + 0.001), curve(t - 0.001))));
      }
      N[0] = norm(cross(T[0], [0, 0, 1]));
      for (i = 1; i <= segs; i++) N[i] = norm(sub(N[i - 1], T[i].map(function (v) { return v * dot(N[i - 1], T[i]); })));
      // Close the loop: spread out any leftover twist along the tube.
      var B0 = cross(T[0], N[0]);
      var gap = Math.atan2(dot(N[segs], B0), dot(N[segs], N[0]));
      var rings = [];
      for (i = 0; i <= segs; i++) {
        var a0 = -gap * i / segs, B = cross(T[i], N[i]);
        var n2 = [N[i][0] * Math.cos(a0) + B[0] * Math.sin(a0), N[i][1] * Math.cos(a0) + B[1] * Math.sin(a0), N[i][2] * Math.cos(a0) + B[2] * Math.sin(a0)];
        var b2 = cross(T[i], n2), ring = [];
        for (var k = 0; k < SIDES; k++) {
          var a = k / SIDES * Math.PI * 2, ca = Math.cos(a), sa = Math.sin(a);
          var dir = [n2[0] * ca + b2[0] * sa, n2[1] * ca + b2[1] * sa, n2[2] * ca + b2[2] * sa];
          ring.push({ p: [C[i][0] + dir[0] * rad, C[i][1] + dir[1] * rad, C[i][2] + dir[2] * rad], n: dir });
        }
        rings.push(ring);
      }
      return rings;
    }

    function gcd(a, b) { return b ? gcd(b, a % b) : a; }

    function setup(el) {
      var d = el.dataset;
      var p = Math.max(1, Math.round(+d.p || DEFAULTS.p)), q = Math.max(1, Math.round(+d.q || DEFAULTS.q));
      if (gcd(p, q) !== 1) { p = DEFAULTS.p; q = DEFAULTS.q; }
      var cols = (d.colors || DEFAULTS.colors).split(',').map(rgb);
      var rad = Math.max(0.05, Math.min(0.3, +d.thickness || DEFAULTS.thickness));
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var segs = Math.min(320, 70 * Math.max(p, q));
      var rings = tube(p, q, rad, segs);
      var segCol = [];
      for (var i = 0; i < segs; i++) {
        // Out along the colours and back again, so the loop has no seam.
        var f = (i + 0.5) / segs * 2, x = (f > 1 ? 2 - f : f) * (cols.length - 1);
        var k0 = Math.min(cols.length - 1, Math.floor(x)), k1 = Math.min(cols.length - 1, k0 + 1), m = x - k0;
        segCol.push([0, 1, 2].map(function (c) { return cols[k0][c] + (cols[k1][c] - cols[k0][c]) * m; }));
      }
      var canvas = document.createElement('canvas');
      canvas.className = 'tkn-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.2, t: 0, v: 0, dragging: false };
      var L = norm([-0.45, -0.6, 0.65]);
      var HALF = norm([L[0], L[1], L[2] + 1]);

      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, h) * 0.4;
        var ox = w / 2, oy = h / 2;
        var cy = Math.cos(s.yaw), sy = Math.sin(s.yaw);
        var pitch = 0.55 + Math.sin(s.t * 0.3) * 0.35, cp = Math.cos(pitch), sp = Math.sin(pitch);
        var roll = s.t * 0.12, cr = Math.cos(roll), sr = Math.sin(roll);
        // Screen axes: x right, y down, z towards us.
        function rot(v) {
          var x = v[0] * cr - v[1] * sr, y = v[0] * sr + v[1] * cr, z = v[2];
          var x2 = x * cy + z * sy, z2 = -x * sy + z * cy;
          return [x2, y * cp - z2 * sp, y * sp + z2 * cp];
        }
        var V = rings.map(function (ring) {
          return ring.map(function (o) {
            var v = rot(o.p), k = 5 / (5 - v[2]);
            return { x: ox + v[0] * R * k, y: oy + v[1] * R * k, z: v[2], n: rot(o.n) };
          });
        });
        var quads = [];
        for (var i = 0; i < segs; i++) for (var j = 0; j < SIDES; j++) {
          var j2 = (j + 1) % SIDES;
          var a = V[i][j], b = V[i + 1][j], c = V[i + 1][j2], e = V[i][j2];
          var n = norm([a.n[0] + b.n[0] + c.n[0] + e.n[0], a.n[1] + b.n[1] + c.n[1] + e.n[1], a.n[2] + b.n[2] + c.n[2] + e.n[2]]);
          if (n[2] < -0.2) continue; // round the back of the tube
          quads.push({ a: a, b: b, c: c, e: e, n: n, i: i, z: (a.z + b.z + c.z + e.z) / 4 });
        }
        quads.sort(function (m, o) { return m.z - o.z; });
        ctx.lineJoin = 'round';
        ctx.lineWidth = 0.8;
        quads.forEach(function (o) {
          var lam = Math.max(0, dot(o.n, L));
          var spec = Math.pow(Math.max(0, dot(o.n, HALF)), 40);
          var rim = Math.pow(1 - Math.max(0, o.n[2]), 3) * 0.25;
          var base = segCol[o.i];
          var k = 0.32 + lam * 0.78;
          var col = 'rgb(' + base.map(function (v) {
            var x = v * k + (255 - v * k) * Math.min(1, spec * 1.2 + rim * 0.4);
            return Math.round(Math.min(255, x));
          }).join(',') + ')';
          ctx.fillStyle = col;
          ctx.strokeStyle = col;
          ctx.beginPath();
          ctx.moveTo(o.a.x, o.a.y);
          ctx.lineTo(o.b.x, o.b.y);
          ctx.lineTo(o.c.x, o.c.y);
          ctx.lineTo(o.e.x, o.e.y);
          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;
          if (!s.dragging) {
            s.yaw += (0.4 * 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-torus-knot]').forEach(function (el) {
        if (el._tkn) return;
        el._tkn = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-torus-knot style="height: 320px"></div>

<!-- or a five-lobed knot: -->
<div data-torus-knot data-p="2" data-q="5" data-thickness="0.09" style="height: 320px"></div>