← Seasonal

Frosted Window Edges

Frost creeps in from the edges like ice on a winter window. Feathery crystals grow inward from every side, reaching furthest in the corners, while a misty, blurred rim thickens round the edge. The middle stays clear for your content.

Stay warm

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-frosted-window-edges to any section, or to the page’s main wrapper for a frosted screen. The frost starts growing when it scrolls into view.

Options

Add any of these attributes to the same element.

data-loopThaw after a while and grow back in a new pattern.
data-durationHow long the frost takes to grow. Default 4500 ms.
data-depthHow far it reaches in, as a fraction of the section’s shorter side. Default 0.3.
data-colorFrost colour. Default icy white #f0f9ff.
data-blur="false"Crystals only, without the blurred, misty rim.

The frost sits over your content but never catches clicks. It is drawn to fit the section and grown afresh when it changes size. With reduced motion the frost is simply there.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Frosted Window Edges: frost creeps in from the edges of a section like
    // ice on a winter window. Feathery crystals grow inward from every side,
    // reaching furthest in the corners, while a misty, blurred rim thickens
    // round the edge. The middle stays clear for your content. Loop it and
    // the frost thaws away and grows back in a new pattern.
    // Usage: <section data-frosted-window-edges>…</section>
    // Optional: data-loop (thaw and grow again), data-duration (ms for the
    // frost to grow, default 4500), data-depth (how far it reaches in, as a
    // fraction of the section's shorter side, default 0.3), data-color
    // (frost colour, default #f0f9ff), data-blur="false" (crystals only, no
    // blurred rim).
    var DEFAULTS = { duration: 4500, depth: 0.3, color: '#f0f9ff' };

    var css =
      '[data-frosted-window-edges]{position:relative;overflow:hidden}' +
      '.fwe-haze,.fwe-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;display:block}' +
      '.fwe-haze{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function rnd(a, b) { return a + Math.random() * (b - a); }

    function rgb(hex) {
      var h = hex.replace('#', '');
      if (h.length === 3) h = h.replace(/./g, '$&$&');
      return [0, 2, 4].map(function (i) { return parseInt(h.substr(i, 2), 16); }).join(',');
    }

    // Grow one feathery crystal: a wandering spine with side shoots at ice's
    // sixty degrees, each shoot shorter the nearer it is to the tip.
    function crystal(segs, x, y, ang, len, level, t0) {
      var step = 3, n = Math.max(2, Math.round(len / step));
      var bend = rnd(-0.03, 0.03);
      var nextShoot = rnd(3, 6), side = Math.random() < 0.5 ? 1 : -1;
      for (var i = 0; i < n; i++) {
        ang += bend + rnd(-0.06, 0.06);
        var nx = x + Math.cos(ang) * step, ny = y + Math.sin(ang) * step;
        segs.push({ x1: x, y1: y, x2: nx, y2: ny, w: level, t: t0 + i * step });
        x = nx; y = ny;
        if (level < 2 && i * step >= nextShoot) {
          var left = (n - i) * step;
          var sl = Math.min(left * rnd(0.25, 0.45), level ? 7 : 26) * rnd(0.7, 1);
          if (sl > 3) {
            crystal(segs, x, y, ang + side * 1.05, sl, level + 1, t0 + i * step);
            crystal(segs, x, y, ang - side * 1.05, sl * rnd(0.7, 1), level + 1, t0 + i * step);
          }
          side = -side;
          nextShoot += level ? rnd(3, 5) : rnd(4, 8);
        }
      }
    }

    function build(el) {
      var d = el.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = Math.max(500, d.duration !== undefined ? +d.duration : DEFAULTS.duration);
      var depth = Math.max(0.05, Math.min(0.6, d.depth !== undefined ? +d.depth : DEFAULTS.depth));
      var color = rgb(d.color || DEFAULTS.color);
      var blur = d.blur !== 'false';

      var haze = null;
      if (blur) {
        haze = document.createElement('div');
        haze.className = 'fwe-haze';
        haze.setAttribute('aria-hidden', 'true');
        haze.style.background = 'rgba(' + color + ',.3)';
        el.appendChild(haze);
      }
      var canvas = document.createElement('canvas');
      canvas.className = 'fwe-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');

      var W = 0, H = 0, dpr = 1, reach = 0, segs = [], drawn = 0;
      var S = { p: 0, o: 1 };

      function generate() {
        segs = [];
        reach = Math.min(W, H) * depth;
        var gap = 20;
        function edge(x0, y0, x1, y1, nx, ny) {
          var len = Math.hypot(x1 - x0, y1 - y0), n = Math.max(2, Math.round(len / gap));
          for (var i = 0; i <= n; i++) {
            var u = (i + rnd(-0.4, 0.4)) / n;
            var x = x0 + (x1 - x0) * u, y = y0 + (y1 - y0) * u;
            // Longer crystals near the corners.
            var dc = Math.min(Math.hypot(x, y), Math.hypot(W - x, y), Math.hypot(x, H - y), Math.hypot(W - x, H - y));
            var corner = Math.max(0, 1 - dc / (Math.min(W, H) * 0.45));
            var len2 = reach * rnd(0.25, 0.85) * (1 + corner * 1.1);
            crystal(segs, x, y, Math.atan2(ny, nx) + rnd(-0.5, 0.5), len2, 0, rnd(0, 0.18) * reach);
          }
        }
        edge(0, 0, W, 0, 0, 1);
        edge(W, 0, W, H, -1, 0);
        edge(W, H, 0, H, 0, -1);
        edge(0, H, 0, 0, 1, 0);
        [[0, 0], [W, 0], [W, H], [0, H]].forEach(function (c) {
          for (var k = 0; k < 3; k++) crystal(segs, c[0], c[1], Math.atan2(H / 2 - c[1], W / 2 - c[0]) + rnd(-0.35, 0.35), reach * rnd(1.4, 2), 0, 0);
        });
        var max = 0;
        segs.forEach(function (s) { if (s.t > max) max = s.t; });
        segs.forEach(function (s) { s.t /= max || 1; });
        segs.sort(function (a, b) { return a.t - b.t; });
      }

      function clear() {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        drawn = 0;
      }

      var WIDTH = [1.4, 0.9, 0.6], ALPHA = [0.7, 0.5, 0.35];
      function drawTo(p) {
        var end = drawn;
        while (end < segs.length && segs[end].t <= p) end++;
        if (end === drawn) return;
        ctx.lineCap = 'round';
        for (var lv = 0; lv < 3; lv++) {
          ctx.beginPath();
          for (var i = drawn; i < end; i++) {
            var s = segs[i];
            if (s.w !== lv) continue;
            ctx.moveTo(s.x1, s.y1);
            ctx.lineTo(s.x2, s.y2);
          }
          ctx.lineWidth = WIDTH[lv] * 3.5;
          ctx.strokeStyle = 'rgba(' + color + ',' + (ALPHA[lv] * 0.12).toFixed(3) + ')';
          ctx.stroke();
          ctx.lineWidth = WIDTH[lv];
          ctx.strokeStyle = 'rgba(' + color + ',' + ALPHA[lv] + ')';
          ctx.stroke();
        }
        drawn = end;
      }

      function render() {
        drawTo(S.p);
        canvas.style.opacity = S.o;
        if (haze) {
          var p = S.p;
          var inner = 100 - p * 46, outer = inner + 38;
          var m = 'radial-gradient(ellipse farthest-corner at 50% 50%, transparent ' + inner.toFixed(1) + '%, #000 ' + outer.toFixed(1) + '%)';
          haze.style.webkitMaskImage = m;
          haze.style.maskImage = m;
          haze.style.opacity = (Math.min(1, p * 1.6) * S.o).toFixed(3);
        }
      }

      function layout() {
        var w = el.clientWidth, h = el.clientHeight;
        if (!w || !h || (w === W && h === H)) return;
        W = w; H = h;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(W * dpr);
        canvas.height = Math.round(H * dpr);
        generate();
        clear();
        render();
      }
      layout();
      var rt = 0;
      if (window.ResizeObserver) new ResizeObserver(function () { clearTimeout(rt); rt = setTimeout(layout, 120); }).observe(el);

      if (reduced()) { S.p = 1; render(); return; }

      var tl = null, visible = false, pending = false;
      function grow() {
        if (tl) tl.pause();
        clear();
        S.p = 0; S.o = 1;
        render();
        tl = anime.createTimeline({
          onUpdate: render,
          onComplete: function () {
            tl = null;
            if (!loop) return;
            if (visible) { generate(); grow(); } else pending = true;
          }
        });
        tl.add(S, { p: [0, 1], duration: duration, ease: 'out(1.2)' }, 200);
        if (loop) {
          tl.add(S, { o: [1, 0], duration: 1400, ease: 'inOut(2)' }, 200 + duration + 3000)
            .add({ duration: 700 });
        }
      }

      var started = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible && !started) { started = true; grow(); }
        else if (visible && pending) { pending = false; generate(); grow(); }
        else if (visible && tl) tl.play();
        else if (!visible && tl) tl.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-frosted-window-edges]').forEach(function (el) {
        if (el._fwe) return;   // idempotent
        el._fwe = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-frosted-window-edges>
  <h2>Stay warm</h2>
</section>

<!-- deeper, slower, again and again: -->
<section data-frosted-window-edges data-loop data-depth="0.45" data-duration="8000">…</section>