← 3D & depth

3D Tag Sphere

Words sit evenly over the surface of a sphere that turns slowly in space; the ones round the back shrink and fade. Drag or flick it to spin it, and with a mouse it leans towards wherever you point.

CSS SVG Canvas anime.js Easing Motion HTML Keyframes WebGL Scroll Hover Spring Bezier Transform Opacity Stagger Timeline Loop 3D Blur Gradient Path Morph Delay

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-tag-sphere to a box with a height. Each child is a tag: words, links or icons.

Options

Add any of these attributes to the same element.

data-radiusRadius of the sphere in px. Default fits the box.
data-speedHow fast it turns when left alone, in degrees per second. Default 14.
data-follow="false"Don’t lean towards the mouse pointer.
data-drag="false"Don’t let people spin it by dragging.

Links on the front half stay clickable; a drag doesn’t count as a click. Between about 15 and 40 tags looks best.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // 3D Tag Sphere: words or icons sit evenly over a sphere that turns
    // slowly. The ones at the back shrink and fade. Drag it (or flick it) to
    // spin it; with a mouse it also leans towards the pointer.
    // Usage:
    //   <div data-tag-sphere style="height: 360px">
    //     <a href="/css">CSS</a> <a href="/svg">SVG</a> <span>Canvas</span> …
    //   </div>
    // The children are the tags; links stay clickable.
    // Optional: data-radius (px, default fits the box), data-speed (degrees per
    // second when idle, default 14), data-follow="false" (don't lean towards
    // the pointer), data-drag="false".
    var DEFAULTS = { speed: 14 };

    var css =
      '.tgs{position:relative;overflow:hidden;touch-action:pan-y;user-select:none;-webkit-user-select:none}' +
      '.tgs-drag{cursor:grab}.tgs-drag:active{cursor:grabbing}' +
      '.tgs-tag{position:absolute;left:50%;top:50%;white-space:nowrap;will-change:transform,opacity}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Rotate point p about the X axis by a, then the Y axis by b.
    function turn(p, a, b) {
      var ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b);
      var y = p[1] * ca - p[2] * sa, z = p[1] * sa + p[2] * ca;
      var x = p[0] * cb + z * sb;
      z = -p[0] * sb + z * cb;
      return [x, y, z];
    }

    function setup(el) {
      var d = el.dataset;
      var tags = Array.from(el.children);
      if (!tags.length) return;
      el.classList.add('tgs');
      var n = tags.length;
      // Even spacing over the sphere: a Fibonacci spiral.
      var pts = tags.map(function (t, i) {
        t.classList.add('tgs-tag');
        var y = 1 - (2 * i + 1) / n;
        var rr = Math.sqrt(1 - y * y), th = i * Math.PI * (3 - Math.sqrt(5));
        return [Math.cos(th) * rr, y, Math.sin(th) * rr];
      });
      var sizes = [];
      var speed = (d.speed !== undefined ? +d.speed : DEFAULTS.speed) * Math.PI / 180;
      var R = 0;
      // Spin velocity in radians per second, about X (vx) and Y (vy).
      var s = { vx: speed * 0.25, vy: speed, dragging: false };
      var lean = null;

      function size() {
        R = +d.radius || Math.min(el.clientWidth, el.clientHeight) * 0.38;
        sizes = tags.map(function (t) { return [t.offsetWidth, t.offsetHeight]; });
        draw();
      }

      function draw() {
        for (var i = 0; i < n; i++) {
          var p = pts[i];
          var k = (p[2] + 2) / 3;            // 1/3 at the back, 1 at the front
          var sc = 0.55 + 0.6 * k;
          var t = tags[i];
          t.style.transform = 'translate(' + (p[0] * R - sizes[i][0] / 2) + 'px,' + (p[1] * R - sizes[i][1] / 2) + 'px) scale(' + sc.toFixed(3) + ')';
          t.style.opacity = (0.15 + 0.85 * k * k).toFixed(3);
          t.style.zIndex = Math.round(k * 100);
          // Only the front half takes clicks.
          t.style.pointerEvents = p[2] > -0.1 ? '' : 'none';
        }
      }

      function rotate(ax, ay) {
        for (var i = 0; i < n; i++) {
          var q = turn(pts[i], ax, ay);
          var len = Math.sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2]);   // keep it round
          pts[i] = [q[0] / len, q[1] / len, q[2] / len];
        }
      }

      size();
      if (window.ResizeObserver) new ResizeObserver(size).observe(el);
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(size);
      if (reduced()) return;

      var hover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
      if (d.follow !== 'false' && hover) {
        el.addEventListener('pointermove', function (e) {
          if (s.dragging || e.pointerType !== 'mouse') return;
          var r = el.getBoundingClientRect();
          lean = [(e.clientX - r.left) / r.width - 0.5, (e.clientY - r.top) / r.height - 0.5];
        });
        el.addEventListener('pointerleave', function () { lean = null; });
      }
      if (d.drag !== 'false') {
        el.classList.add('tgs-drag');
        var lx = 0, ly = 0, lt = 0, moved = 0;
        el.addEventListener('pointerdown', function (e) {
          if (e.button) return;
          s.dragging = true;
          moved = 0;
          lx = e.clientX; ly = e.clientY; lt = performance.now();
        });
        el.addEventListener('pointermove', function (e) {
          if (!s.dragging) return;
          var now = performance.now(), dx = e.clientX - lx, dy = e.clientY - ly;
          moved += Math.abs(dx) + Math.abs(dy);
          if (moved > 4 && !el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId);
          var ay = dx / R, ax = -dy / R;
          rotate(ax, ay);
          var dt = Math.max(8, now - lt) / 1000;
          s.vy = ay / dt; s.vx = ax / dt;
          lx = e.clientX; ly = e.clientY; lt = now;
          draw();
        });
        var end = function () { s.dragging = false; };
        el.addEventListener('pointerup', end);
        el.addEventListener('pointercancel', end);
        // A drag isn't a click on the tag under it.
        el.addEventListener('click', function (e) { if (moved > 4) e.preventDefault(); }, true);
      }

      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;
          if (s.dragging) return;
          // Ease towards the idle spin, or towards the pointer if it's over.
          var tx = speed * 0.25, ty = speed;
          if (lean) { ty = lean[0] * speed * 6; tx = -lean[1] * speed * 6; }
          var k = Math.min(1, dt * 1.5);
          s.vx += (tx - s.vx) * k;
          s.vy += (ty - s.vy) * k;
          rotate(s.vx * dt, s.vy * dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-tag-sphere]').forEach(function (el) {
        if (el._tgs) return;
        el._tgs = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-tag-sphere style="height: 360px">
  <a href="/css">CSS</a>
  <a href="/svg">SVG</a>
  <a href="/canvas">Canvas</a>
  <!-- … -->
</div>