← Text effects

Depth Text

A stack of coloured copies sits behind the text and slides the opposite way to the pointer, so the word seems to float above a fan of shadows. On phones, and whenever the pointer is still, it drifts round by itself.

Look deeper

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-depth-text to a heading. Move the pointer anywhere on the page to steer it.

Options

Add any of these attributes to the same element.

data-layersHow many copies sit behind the text. Default 5.
data-depthHow far the back copy travels. Default 0.22 em.
data-colorColour of the copy just behind. Default #a855f7.
data-fadeColour of the furthest copy. Default #e9d5ff.
data-outlineDraw the copies as outlines instead of solid colour.
data-autoplayDrift round by itself whenever no real pointer is moving. Handy for demos and touch screens.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Depth Text: layered copies of the text sit behind it and slide against
    // the pointer, so the word seems to float above a stack of shadows.
    // Usage: <h1 data-depth-text>Look deeper</h1>
    // Optional: data-layers (copies behind the text; default 5), data-depth
    // (how far the back copy travels, in em; default 0.22), data-color (colour
    // of the copy just behind; default #a855f7), data-fade (colour of the
    // furthest copy; default #e9d5ff), data-outline (draw the copies as outlines
    // instead of solid), data-autoplay (a pretend pointer drifts around while no
    // real one is moving; handy for demos and touch screens).
    var DEFAULTS = { layers: 5, depth: 0.22, color: '#a855f7', fade: '#e9d5ff' };

    var css =
      '.dpt-wrap{position:relative;display:inline-block}' +
      '.dpt-front{position:relative;display:block;will-change:transform}' +
      '.dpt-layer{position:absolute;left:0;top:0;width:100%;height:100%;display:block;will-change:transform}' +
      '.dpt-outline .dpt-layer{color:transparent!important;-webkit-text-stroke:.025em var(--dpt-c)}';
    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 n = Math.max(1, Math.min(12, +d.layers || DEFAULTS.layers));
      var depth = d.depth !== undefined ? +d.depth : DEFAULTS.depth;
      var near = d.color || DEFAULTS.color;
      var far = d.fade || DEFAULTS.fade;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';

      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var wrap = document.createElement('span');
      wrap.className = 'dpt-wrap';
      wrap.setAttribute('aria-hidden', 'true');
      if (d.outline !== undefined && d.outline !== 'false') wrap.classList.add('dpt-outline');

      var layers = [];
      for (var i = n; i >= 1; i--) {
        var s = document.createElement('span');
        s.className = 'dpt-layer';
        s.textContent = text;
        var c = 'color-mix(in srgb, ' + far + ' ' + Math.round(100 * (i - 1) / Math.max(1, n - 1)) + '%, ' + near + ')';
        s.style.color = c;
        s.style.setProperty('--dpt-c', c);
        wrap.appendChild(s);
        layers.unshift({ el: s, k: i / n });
      }
      var front = document.createElement('span');
      front.className = 'dpt-front';
      front.textContent = text;
      wrap.appendChild(front);
      el.appendChild(wrap);

      var fs = 16;
      function measure() { fs = parseFloat(getComputedStyle(el).fontSize); }
      measure();
      new ResizeObserver(measure).observe(el);

      // Where the layers sit for a pointer offset (ox, oy), each from -1 to 1.
      function place(ox, oy) {
        var far = depth * fs;
        front.style.transform = 'translate(' + (ox * far * 0.25).toFixed(2) + 'px,' + (oy * far * 0.25).toFixed(2) + 'px)';
        layers.forEach(function (l) {
          l.el.style.transform = 'translate(' + (-ox * far * l.k).toFixed(2) + 'px,' + (-oy * far * l.k).toFixed(2) + 'px)';
        });
      }

      if (reduced()) { place(0.6, 0.6); return; }

      var target = { x: 0.6, y: 0.6 };
      var cur = { x: 0, y: 0 };
      var lastReal = -1e9;
      var clock = 0;

      function onPointer(e) {
        var r = el.getBoundingClientRect();
        if (r.bottom < 0 || r.top > innerHeight) return;
        var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
        var reach = Math.max(160, Math.min(r.width, innerWidth) * 0.6);
        target.x = Math.max(-1, Math.min(1, (e.clientX - cx) / reach));
        target.y = Math.max(-1, Math.min(1, (e.clientY - cy) / reach));
        lastReal = performance.now();
      }
      window.addEventListener('pointermove', onPointer, { passive: true });
      window.addEventListener('pointerdown', onPointer, { passive: true });

      var prev = 0;
      var ticker = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = prev ? Math.min(50, now - prev) : 16;
          prev = now;
          clock += dt;
          if (autoplay && now - lastReal > 2000) {
            // A slow figure-of-eight wander.
            target.x = Math.sin(clock * 0.0011) * 0.95;
            target.y = Math.sin(clock * 0.0019 + 1) * 0.75;
          }
          var ease = 1 - Math.pow(0.9, dt / 16);
          cur.x += (target.x - cur.x) * ease;
          cur.y += (target.y - cur.y) * ease;
          place(cur.x, cur.y);
        }
      });
      place(0, 0);

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
        else ticker.pause();
      }).observe(el);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-depth-text data-autoplay>Look deeper</h1>

<!-- outlined copies in blue: -->
<h1 data-depth-text data-outline data-color="#2563eb" data-fade="#bfdbfe">Look deeper</h1>