← Text effects

Frost Text

Ice crystals creep across the letters from left to right, branching like frost on a window, and little glints sparkle over the frozen words. Loop it and the frost thaws away and grows back, different every time.

Deep freeze

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-frost-text to a heading. It looks best in a pale blue on a dark background.

Options

Add any of these attributes to the same element.

data-loopThaw and freeze again, with fresh crystals each time.
data-durationHow long the frost takes to creep across. Default 2600 ms.
data-holdHow long it stays frozen before thawing, when looping. Default 2800 ms.
data-colorColour of the letters. Default the text colour.
data-frostColour of the crystals and glints. Default #ffffff.
data-glintsSparkles per second. Default 5.

Each word is drawn on a canvas in the element’s own font (screen readers still get the text). With reduced motion turned on the words show fully frosted, without sparkles.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Frost Text: ice crystals creep across the letters from left to right,
    // branching like frost on a window, and little glints sparkle over them.
    // Loop it and the frost thaws away and grows back.
    // Usage: <h1 data-frost-text>Frozen</h1>
    // Optional: data-loop (thaw and freeze again), data-duration (ms for the
    // frost to creep across; default 2600), data-hold (ms it stays frozen
    // before thawing, when looping; default 2800), data-color (colour of the
    // letters; default the text colour), data-frost (colour of the crystals;
    // default #ffffff), data-glints (sparkles per second; default 5).
    // The text is drawn on a canvas per word, using the element's font.
    var DEFAULTS = { duration: 2600, hold: 2800, frost: '#ffffff', glints: 5 };

    var css =
      '.frt-word{display:inline-block;vertical-align:top}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    var PAD = 0.18; // room round each word for glints, in em

    // Any CSS colour as [r, g, b], so the canvas can mix and fade it.
    function rgb(color, el) {
      var probe = document.createElement('span');
      probe.style.color = color;
      probe.style.display = 'none';
      el.appendChild(probe);
      var m = getComputedStyle(probe).color.match(/[\d.]+/g) || [255, 255, 255];
      el.removeChild(probe);
      return [+m[0], +m[1], +m[2]];
    }
    function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
    function mix(a, b, k) {
      return [0, 1, 2].map(function (i) { return Math.round(a[i] * (1 - k) + b[i] * k); });
    }

    function setup(el) {
      var d = el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var frostRGB = rgb(d.frost || DEFAULTS.frost, el);
      var frost = rgba(frostRGB, 1);
      var glintRate = d.glints !== undefined ? +d.glints : DEFAULTS.glints;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var still = reduced();

      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var words = text.split(' ').map(function (w, i) {
        if (i) {
          // A zero-size space, so lines can still break between words.
          var gap = document.createElement('span');
          gap.style.fontSize = '0';
          gap.textContent = ' ';
          el.appendChild(gap);
        }
        var cv = document.createElement('canvas');
        cv.className = 'frt-word';
        cv.setAttribute('aria-hidden', 'true');
        el.appendChild(cv);
        return { text: w, cv: cv, ctx: cv.getContext('2d'), mask: document.createElement('canvas'), ice: document.createElement('canvas') };
      });

      var fs = 16, lh = 20, dpr = 1, totalW = 1;
      var thaw = 900;
      var cycle = duration + 900 + hold + thaw;
      var clock = 0;

      // Frost for one word: branches, each a list of short segments that appear
      // at set times, so the ice seems to grow.
      function grow(w) {
        var segs = [];
        var unit = fs;
        function branch(x, y, ang, len, depth, t) {
          var steps = depth === 2 ? 5 : depth === 1 ? 3 : 1;
          var step = len / steps;
          var speed = unit * (depth === 2 ? 1.1 : 1.6) / 1000; // px per ms
          for (var i = 0; i < steps; i++) {
            ang += (Math.random() - 0.5) * 0.25;
            var nx = x + Math.cos(ang) * step, ny = y + Math.sin(ang) * step;
            var t1 = t + step / speed;
            segs.push({ x0: x, y0: y, x1: nx, y1: ny, t: t1, wdt: unit * (0.016 + depth * 0.012), a: 0.55 + depth * 0.2 });
            if (depth > 0 && i < steps - 1 || depth === 2) {
              var side = Math.random() < 0.5 ? 1 : -1;
              branch(nx, ny, ang + side * Math.PI / 3, len * 0.42, depth - 1, t1);
              if (Math.random() < 0.7) branch(nx, ny, ang - side * Math.PI / 3, len * 0.36, depth - 1, t1);
            }
            x = nx; y = ny; t = t1;
          }
        }
        var seeds = Math.max(3, Math.round(w.textW / (unit * 0.26)));
        for (var s = 0; s < seeds; s++) {
          var sx = w.pad + Math.random() * w.textW;
          var sy = w.pad + lh * (0.2 + Math.random() * 0.6);
          // Frost creeps in from the left of the whole line.
          var t0 = (w.offset + sx - w.pad) / totalW * duration * 0.85 + Math.random() * duration * 0.15;
          var arms = 3 + Math.floor(Math.random() * 3);
          var a0 = Math.random() * Math.PI * 2;
          for (var k = 0; k < arms; k++) {
            branch(sx, sy, a0 + k * Math.PI * 2 / arms + (Math.random() - 0.5) * 0.6, unit * (0.28 + Math.random() * 0.22), 2, t0);
          }
          segs.push({ dot: true, x0: sx, y0: sy, r: unit * 0.45, t: t0 });
        }
        segs.sort(function (a, b) { return a.t - b.t; });
        w.segs = segs;
        w.next = 0;
        var g = w.ice.getContext('2d');
        g.setTransform(1, 0, 0, 1, 0, 0);
        g.clearRect(0, 0, w.ice.width, w.ice.height);
        g.setTransform(dpr, 0, 0, dpr, 0, 0);
        g.lineCap = 'round';
      }

      // Draw onto the ice layer every segment that has grown by time t.
      function freezeTo(w, t) {
        var g = w.ice.getContext('2d');
        g.strokeStyle = frost;
        while (w.next < w.segs.length && w.segs[w.next].t <= t) {
          var s = w.segs[w.next++];
          if (s.dot) {
            // A soft haze where each crystal starts.
            var grad = g.createRadialGradient(s.x0, s.y0, 0, s.x0, s.y0, s.r);
            grad.addColorStop(0, rgba(frostRGB, 0.35));
            grad.addColorStop(1, rgba(frostRGB, 0));
            g.globalAlpha = 1;
            g.fillStyle = grad;
            g.fillRect(s.x0 - s.r, s.y0 - s.r, s.r * 2, s.r * 2);
            continue;
          }
          g.globalAlpha = s.a;
          g.lineWidth = s.wdt;
          g.beginPath();
          g.moveTo(s.x0, s.y0);
          g.lineTo(s.x1, s.y1);
          g.stroke();
        }
        g.globalAlpha = 1;
      }

      function build() {
        var cs = getComputedStyle(el);
        fs = parseFloat(cs.fontSize);
        lh = cs.lineHeight === 'normal' ? fs * 1.2 : parseFloat(cs.lineHeight);
        dpr = Math.min(2, window.devicePixelRatio || 1);
        var font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
        var base = rgb(d.color || cs.color, el);
        var pad = Math.round(fs * PAD);
        var x = 0, spaceW = 0;
        words.forEach(function (w) {
          var m = w.ctx;
          m.font = font;
          if ('letterSpacing' in m) m.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
          spaceW = Math.max(m.measureText(' ').width, fs * 0.24);
          var mt = m.measureText(w.text);
          w.textW = mt.width;
          w.pad = pad;
          w.cssW = Math.ceil(w.textW + pad * 2);
          w.cssH = Math.ceil(lh + pad * 2);
          w.offset = x;
          x += w.textW + spaceW;
          var asc = mt.fontBoundingBoxAscent || fs * 0.9, desc = mt.fontBoundingBoxDescent || fs * 0.25;
          var baseline = pad + (lh - (asc + desc)) / 2 + asc;
          [w.cv, w.mask, w.ice].forEach(function (c) {
            c.width = Math.round(w.cssW * dpr);
            c.height = Math.round(w.cssH * dpr);
          });
          w.cv.style.width = w.cssW + 'px';
          w.cv.style.height = w.cssH + 'px';
          w.cv.style.margin = (-pad) + 'px';
          if (w !== words[0]) w.cv.style.marginLeft = (spaceW - pad) + 'px';
          // The letters, drawn once in their base colour with a cold sheen.
          var g = w.mask.getContext('2d');
          g.setTransform(dpr, 0, 0, dpr, 0, 0);
          g.font = font;
          if ('letterSpacing' in g) g.letterSpacing = m.letterSpacing;
          var grad = g.createLinearGradient(0, pad, 0, pad + lh);
          grad.addColorStop(0, rgba(mix(base, [255, 255, 255], 0.3), 1));
          grad.addColorStop(0.55, rgba(base, 1));
          grad.addColorStop(1, rgba(mix(base, [10, 42, 85], 0.2), 1));
          g.fillStyle = grad;
          g.fillText(w.text, pad, baseline);
          w.ctx.setTransform(1, 0, 0, 1, 0, 0);
          w.glints = [];
        });
        totalW = Math.max(1, x - spaceW);
        words.forEach(function (w) {
          grow(w);
          freezeTo(w, still ? Infinity : clock);
          draw(w, 1);
        });
      }

      function draw(w, iceAlpha) {
        var c = w.ctx;
        c.setTransform(1, 0, 0, 1, 0, 0);
        c.clearRect(0, 0, w.cv.width, w.cv.height);
        c.globalCompositeOperation = 'source-over';
        c.drawImage(w.mask, 0, 0);
        // Ice only where there are letters.
        c.globalCompositeOperation = 'source-atop';
        c.globalAlpha = iceAlpha;
        c.drawImage(w.ice, 0, 0);
        c.globalAlpha = 1;
        c.globalCompositeOperation = 'source-over';
        c.setTransform(dpr, 0, 0, dpr, 0, 0);
        w.glints.forEach(function (gl) {
          var r = gl.age / gl.life;
          var k = Math.sin(Math.PI * r) * iceAlpha;
          var s = fs * 0.16 * gl.size * k;
          c.globalAlpha = k;
          c.strokeStyle = frost;
          c.lineWidth = Math.max(1, fs * 0.018);
          c.beginPath();
          c.moveTo(gl.x - s, gl.y); c.lineTo(gl.x + s, gl.y);
          c.moveTo(gl.x, gl.y - s); c.lineTo(gl.x, gl.y + s);
          c.stroke();
          var glow = c.createRadialGradient(gl.x, gl.y, 0, gl.x, gl.y, s * 0.6);
          glow.addColorStop(0, frost);
          glow.addColorStop(1, rgba(frostRGB, 0));
          c.fillStyle = glow;
          c.fillRect(gl.x - s, gl.y - s, s * 2, s * 2);
        });
        c.globalAlpha = 1;
      }

      function addGlint() {
        // Sparkle at the tip of a crystal that has already grown.
        var w = words[Math.floor(Math.random() * words.length)];
        if (!w.next) return;
        var s = w.segs[Math.floor(Math.random() * w.next)];
        if (s.dot) return;
        w.glints.push({ x: s.x1, y: s.y1, age: 0, life: 650 + Math.random() * 500, size: 0.7 + Math.random() * 0.6 });
      }

      build();
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(build);
      var lastW = el.clientWidth;
      new ResizeObserver(function () {
        if (Math.abs(el.clientWidth - lastW) < 1 && fs === parseFloat(getComputedStyle(el).fontSize)) return;
        lastW = el.clientWidth;
        build();
      }).observe(el);
      if (still) return;

      var prev = 0, owed = 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;
          var t = clock;
          if (loop && t >= cycle) {
            clock = t = 0;
            words.forEach(function (w) { w.glints = []; grow(w); });
          }
          var iceAlpha = 1;
          if (loop && t > cycle - thaw) iceAlpha = Math.max(0, (cycle - t) / thaw);
          if (t > duration * 0.4) {
            owed += glintRate * dt / 1000 * Math.min(1, (t - duration * 0.4) / duration);
            while (owed >= 1) { addGlint(); owed--; }
          }
          words.forEach(function (w) {
            freezeTo(w, t);
            for (var i = w.glints.length - 1; i >= 0; i--) {
              w.glints[i].age += dt;
              if (w.glints[i].age >= w.glints[i].life) w.glints.splice(i, 1);
            }
            draw(w, iceAlpha);
          });
        }
      });

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

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-frost-text data-loop style="color: #8ec9ff">Deep freeze</h1>