← Text effects

Perspective Text Tunnel

Copies of a word appear far away in the middle of the box and stream towards you, growing, brightening and turning a little as they come, until they fly past the edges. An endless tunnel built from one word.

DEEPER

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-perspective-text-tunnel to a box holding one short word, and give the box a height and a dark background.

Options

Add any of these attributes to the same element.

data-copiesHow many copies are in the tunnel at once. Default 9.
data-speedSeconds for a copy to travel from the far end to past you. Default 6.
data-twistDegrees each copy is turned from the one behind it, which makes the tunnel spiral. Default 5; 0 for straight.
data-styleoutline (default) or solid letters.
data-colorColour of the nearest copies. Default the text colour.
data-farColour of the distant copies. Default #a855f7.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Perspective Text Tunnel: copies of a word appear far away in the middle
    // of the box and stream towards you, growing and turning slightly as they
    // come, then fly past the edges. An endless tunnel made of one word.
    // Usage: <div data-perspective-text-tunnel style="height:320px">Deeper</div>
    // The element is the tunnel: give it a height and a background. Its font
    // size sets the copies' size; they grow to about three times that as they
    // fly past.
    // Optional: data-copies (how many copies are in the tunnel at once; default 9),
    // data-speed (seconds for a copy to travel the whole way; default 6),
    // data-twist (degrees each copy is turned from the one behind; default 5),
    // data-style ("outline", the default, or "solid"), data-color (colour of
    // the nearest copies; default the text colour), data-far (colour of the
    // distant copies; default #a855f7).
    var DEFAULTS = { copies: 9, speed: 6, twist: 5, far: '#a855f7' };

    var css =
      '.ptt-box{position:relative;overflow:hidden}' +
      '.ptt-copy{position:absolute;left:50%;top:50%;white-space:nowrap;line-height:1;pointer-events:none}' +
      '.ptt-outline .ptt-copy{color:transparent;-webkit-text-stroke:var(--ptt-stroke,2px) var(--ptt-c)}' +
      '.ptt-solid .ptt-copy{color:var(--ptt-c)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function rgb(color) {
      var probe = document.createElement('span');
      probe.style.color = color;
      document.body.appendChild(probe);
      var m = (getComputedStyle(probe).color.match(/[\d.]+/g) || [0, 0, 0]).map(Number);
      probe.remove();
      return m;
    }

    function setup(el) {
      var d = el.dataset;
      var n = Math.max(2, +d.copies || DEFAULTS.copies);
      var speed = +d.speed || DEFAULTS.speed;
      var twist = d.twist !== undefined ? +d.twist : DEFAULTS.twist;
      var solid = d.style === 'solid';

      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.setAttribute('role', 'img');
      el.textContent = '';
      el.classList.add('ptt-box', solid ? 'ptt-solid' : 'ptt-outline');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';

      var near = rgb(d.color || getComputedStyle(el).color);
      var far = rgb(d.far || DEFAULTS.far);

      var copies = [];
      for (var i = 0; i < n; i++) {
        var s = document.createElement('span');
        s.className = 'ptt-copy';
        s.setAttribute('aria-hidden', 'true');
        s.textContent = text;
        el.appendChild(s);
        copies.push(s);
      }

      var fs = 16;
      function measure() {
        fs = parseFloat(getComputedStyle(el).fontSize);
        el.style.setProperty('--ptt-stroke', Math.max(1, fs / 36).toFixed(1) + 'px');
        if (el.clientHeight < fs * 3) el.style.minHeight = (fs * 4) + 'px';
      }
      measure();
      new ResizeObserver(measure).observe(el);

      // p runs 0 (far away) to 1 (flown past). Depth from 12 units to 0.35.
      var FAR = 12, NEAR = 0.35;
      function draw(t) {
        for (var i = 0; i < n; i++) {
          var p = ((t + i / n) % 1 + 1) % 1;
          var dist = FAR * Math.pow(NEAR / FAR, p);   // evenly spaced rings
          var scale = 1 / dist;
          // Fade in from the vanishing point, out as it flies past.
          var op = Math.min(1, p / 0.18) * Math.min(1, (1 - p) / 0.12);
          var k = Math.min(1, scale);   // how near: 0 far, 1 at full size
          var mix = Math.pow(k, 0.6);
          var c = [0, 1, 2].map(function (j) { return Math.round(far[j] + (near[j] - far[j]) * mix); });
          var rot = -(1 - p) * twist * n;
          var s = copies[i].style;
          s.transform = 'translate(-50%,-50%) rotate(' + rot.toFixed(2) + 'deg) scale(' + scale.toFixed(4) + ')';
          s.opacity = (op * (0.35 + 0.65 * mix)).toFixed(3);
          s.zIndex = Math.round(p * 1000);
          s.setProperty('--ptt-c', 'rgb(' + c.join(',') + ')');
        }
      }

      var t = 0.37;
      draw(t);
      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;
        t = (t + dt / (speed * 1000)) % 1;
        draw(t);
        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-perspective-text-tunnel]').forEach(function (el) {
        if (el._ptt) return;
        el._ptt = true;
        setup(el);
      });
    }

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

<!-- solid letters, no spiral: -->
<div data-perspective-text-tunnel data-style="solid" data-twist="0" style="height: 360px">DEEPER</div>