← Text effects

3D Text Ring

The text wraps round a tilted ring that spins in 3D. Letters swing past bright at the front and fade as they go round the back, where you see them mirrored through the ring.

Text that goes round and round

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-text-ring to a short line of text. The ring’s size comes from the length of the text and its font size.

Options

Add any of these attributes to the same element.

data-speedSeconds per turn. Default 9.
data-tiltHow far the ring leans towards you, in degrees. 0 looks at it edge on. Default 14.
data-directionleft (default) or right.
data-separatorText put after each repeat to close the gap. Default " • ".
data-repeatHow many times the text goes round the ring: more makes a bigger ring. Default 1.
data-backOpacity of letters at the back of the ring. Default 0.18.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // 3D Text Ring: the text is wrapped round a tilted ring that spins in 3D.
    // Letters on the near side are bright; the ones swinging round the back
    // fade and show mirrored, so the ring reads as solid.
    // Usage: <div data-text-ring>Around and around</div>
    // Optional: data-speed (seconds per turn; default 9), data-tilt (how far the
    // ring leans towards you, in degrees; default 14), data-separator (text put
    // between repeats, default " • "), data-repeat (how many times the text
    // goes round; default 1), data-direction ("left", the default, or "right"),
    // data-back (opacity of letters at the back; default 0.18).
    var DEFAULTS = { speed: 9, tilt: 14, separator: ' • ', repeat: 1, back: 0.18 };

    var css =
      '.txr-box{display:inline-block;position:relative;perspective:var(--txr-p,900px)}' +
      '.txr-ring{position:absolute;left:50%;top:50%;width:0;height:0;transform-style:preserve-3d}' +
      '.txr-ch{position:absolute;left:0;top:0;white-space:pre;line-height:1;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; }

    function setup(el) {
      var d = el.dataset;
      var speed = +d.speed || DEFAULTS.speed;
      var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      var sep = d.separator !== undefined ? d.separator : DEFAULTS.separator;
      var repeat = Math.max(1, +d.repeat || DEFAULTS.repeat);
      var back = d.back !== undefined ? +d.back : DEFAULTS.back;
      var dir = d.direction === 'right' ? 1 : -1;

      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.setAttribute('role', 'img');
      el.textContent = '';
      el.classList.add('txr-box');
      var ring = document.createElement('span');
      ring.className = 'txr-ring';
      ring.setAttribute('aria-hidden', 'true');
      el.appendChild(ring);

      var full = '';
      for (var i = 0; i < repeat; i++) full += text + sep;
      var chars = Array.from(full).map(function (c) {
        var s = document.createElement('span');
        s.className = 'txr-ch';
        s.textContent = c;
        ring.appendChild(s);
        return { el: s };
      });

      var r = 0;
      function measure() {
        var total = 0;
        chars.forEach(function (c) {
          c.w = c.el.offsetWidth; c.h = c.el.offsetHeight;
          total += c.w;
        });
        r = total / (Math.PI * 2);
        var fs = parseFloat(getComputedStyle(el).fontSize);
        // Each letter's angle round the ring (centre of the letter).
        var acc = 0;
        chars.forEach(function (c) {
          c.ang = (acc + c.w / 2) / total * 360;
          acc += c.w;
        });
        // Box sized to the ring's footprint, so the layout around it is right.
        var lean = Math.sin(tilt * Math.PI / 180);
        el.style.width = Math.ceil(r * 2 + fs * 0.8) + 'px';
        el.style.height = Math.ceil(fs * 1.2 + r * 2 * lean + fs * 0.5) + 'px';
        el.style.setProperty('--txr-p', Math.round(Math.max(600, r * 6)) + 'px');
      }

      var rot = 0;
      function draw() {
        ring.style.transform = 'rotateX(' + (-tilt) + 'deg)';
        chars.forEach(function (c) {
          var a = c.ang + rot;
          var rad = a * Math.PI / 180;
          var z = Math.cos(rad);  // 1 at the front, -1 at the back
          c.el.style.transform = 'translate(-50%,-50%) rotateY(' + a.toFixed(2) + 'deg) translateZ(' + r.toFixed(1) + 'px)';
          c.el.style.opacity = (back + (1 - back) * Math.pow((z + 1) / 2, 1.6)).toFixed(3);
        });
      }
      measure();
      draw();
      new ResizeObserver(function () { measure(); draw(); }).observe(el);
      if (reduced()) return;

      var running = false, raf = 0, last = 0;
      function frame(now) {
        if (!running) return;
        var dt = Math.min(50, now - last); last = now;
        rot = (rot + dir * dt / (speed * 1000) * 360) % 360;
        draw();
        raf = requestAnimationFrame(frame);
      }
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!running) { running = true; last = performance.now(); raf = requestAnimationFrame(frame); }
        } else {
          running = false; cancelAnimationFrame(raf);
        }
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-text-ring]').forEach(function (el) {
        if (el._txr) return;
        el._txr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-text-ring>Text that goes round and round</div>

<!-- twice round, flatter, spinning the other way: -->
<div data-text-ring data-repeat="2" data-tilt="6" data-direction="right">Spin</div>