← Filters & retro

Frosted Glass Panel

Turn a header, card or toolbar into a pane of frosted glass. Whatever scrolls behind it turns to soft, brighter colour with a fine grain, the rim catches the light, and a faint reflection slides across as the content moves. The frost settles in when it comes into view.

SunriseOceanCitrusFernBerryDuskMossSunriseOceanCitrusFernBerryDuskMossSunriseOceanCitrusFernBerryDuskMoss
Frosted glass Everything behind it blurs as it scrolls past.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-frosted-glass-panel to an element that sits over moving content: a sticky header, a fixed toolbar or a card floating over a scroller.
  3. The reflection follows the page scroll, or the scroller you name with data-autoscroll.

Options

Add any of these attributes to the same element.

data-blurHow frosted the glass is. Default 14 px.
data-tintThe glass colour. Default rgba(255,255,255,.22); try a dark one for dark pages.
data-grainHow much grain, from 0 to 1. Default 0.5.
data-autoscrollA CSS selector for a scrolling element behind the panel, which then drifts up and down by itself until someone scrolls it.
data-speedAutoscroll speed. Default 40 pixels a second.

Browsers without backdrop-filter get a plain, nearly opaque white panel instead.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Frosted Glass Panel: turns an element into a pane of frosted glass. Whatever
    // passes behind it is blurred and brightened, with a fine grain, a bright rim
    // and a soft reflection that slides across as the content behind scrolls.
    // The frost settles in when it comes into view.
    // Usage: <div data-frosted-glass-panel>…</div>, placed over content that moves
    // behind it (a sticky or fixed header, or a panel floating over a scroller).
    // Optional: data-blur (px, default 14), data-tint (the glass colour, default
    // rgba(255,255,255,.22)), data-grain (0 to 1, default .5), data-autoscroll
    // (a CSS selector for a scrolling element to drift up and down by itself
    // while nobody is scrolling it; handy for demos), data-speed (autoscroll
    // pixels per second, default 40).
    var DEFAULTS = { blur: 14, tint: 'rgba(255,255,255,.22)', grain: 0.5, speed: 40 };

    var NOISE = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
      '<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .55 0"/></filter><rect width="140" height="140" filter="url(#n)"/></svg>');

    var css =
      '.fgp{overflow:hidden;isolation:isolate;border-radius:18px;' +
        'background:var(--fgp-tint);border:1px solid rgba(255,255,255,.45);' +
        'box-shadow:inset 0 1px 0 rgba(255,255,255,.6),inset 0 -1px 0 rgba(255,255,255,.15),0 10px 30px rgba(20,10,40,.18);' +
        '-webkit-backdrop-filter:blur(var(--fgp-b)) saturate(var(--fgp-s));backdrop-filter:blur(var(--fgp-b)) saturate(var(--fgp-s))}' +
      '.fgp-grain,.fgp-sheen{position:absolute;inset:0;pointer-events:none;z-index:-1;border-radius:inherit}' +
      '.fgp-grain{background:url("' + NOISE + '");opacity:var(--fgp-g);mix-blend-mode:overlay}' +
      '.fgp-sheen{background:linear-gradient(115deg,transparent 30%,rgba(255,255,255,.28) 45%,rgba(255,255,255,.06) 52%,transparent 60%) no-repeat;background-size:250% 100%;background-position:var(--fgp-p) 0}' +
      '@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.fgp{background:rgba(255,255,255,.82)}}';
    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 blur = d.blur !== undefined ? +d.blur : DEFAULTS.blur;
      var grain = d.grain !== undefined ? +d.grain : DEFAULTS.grain;
      var speed = +d.speed || DEFAULTS.speed;
      el.classList.add('fgp');
      // The grain and reflection layers need a positioned parent; sticky, fixed and absolute already are.
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      el.style.setProperty('--fgp-tint', d.tint || DEFAULTS.tint);
      el.style.setProperty('--fgp-g', grain);
      el.style.setProperty('--fgp-p', '100%');

      ['fgp-grain', 'fgp-sheen'].forEach(function (cls) {
        var layer = document.createElement('span');
        layer.className = cls;
        layer.setAttribute('aria-hidden', 'true');
        el.insertBefore(layer, el.firstChild);
      });

      // The frost: blur and saturation, as one value from 0 (clear) to 1 (frosted).
      var frost = { v: 0 };
      function apply() {
        el.style.setProperty('--fgp-b', (blur * frost.v).toFixed(2) + 'px');
        el.style.setProperty('--fgp-s', (1 + 0.6 * frost.v).toFixed(3));
      }

      // The element scrolling behind: the one named by data-autoscroll, or the page.
      var scroller = null;
      if (d.autoscroll) {
        var p = el.parentNode;
        while (p && p.querySelector && !scroller) {
          scroller = p.matches && p.matches(d.autoscroll) ? p : p.querySelector(d.autoscroll);
          p = p.parentNode;
        }
      }

      // The reflection follows the scroll position, as if the light stays put while things move.
      function sheen() {
        var y, max;
        if (scroller) { y = scroller.scrollTop; max = scroller.scrollHeight - scroller.clientHeight; }
        else { y = window.scrollY; max = document.documentElement.scrollHeight - window.innerHeight; }
        el.style.setProperty('--fgp-p', (100 - (max > 0 ? y / max : 0) * 100).toFixed(1) + '%');
      }
      (scroller || window).addEventListener('scroll', sheen, { passive: true });
      sheen();

      if (reduced()) { frost.v = 1; apply(); return; }
      apply();

      // Autoscroll: drift down to the end and back, pausing whenever someone scrolls or touches it.
      var timer = null;
      if (scroller) {
        var dir = 1, wait = 0, pos = scroller.scrollTop, last = 0, setting = false;
        var pause = function () { wait = performance.now() + 2500; };
        ['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) {
          scroller.addEventListener(t, pause, { passive: true });
        });
        scroller.addEventListener('scroll', function () {
          if (!setting) { pos = scroller.scrollTop; pause(); }
          setting = false;
        }, { passive: true });
        timer = anime.createTimer({
          loop: true, duration: 1000, autoplay: false,
          onUpdate: function () {
            var now = performance.now();
            var dt = last ? Math.min(50, now - last) : 16;
            last = now;
            if (now < wait) return;
            var max = scroller.scrollHeight - scroller.clientHeight;
            if (max <= 0) return;
            pos += dir * speed * dt / 1000;
            if (pos >= max) { pos = max; dir = -1; wait = now + 900; }
            if (pos <= 0) { pos = 0; dir = 1; wait = now + 900; }
            setting = true;
            scroller.scrollTop = pos;
          }
        });
      }

      var shown = false;
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!shown) {
            shown = true;
            anime.animate(frost, { v: 1, duration: 1400, delay: 150, ease: 'out(3)', onUpdate: apply });
          }
          if (timer) { last = 0; timer.play(); }
        } else if (timer) timer.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-frosted-glass-panel]').forEach(function (el) {
        if (el._fgp) return;
        el._fgp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<header data-frosted-glass-panel style="position:sticky; top:0">
  <nav>…</nav>
</header>