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
- Paste the snippet just before
</head>. - Add
data-frosted-glass-panelto an element that sits over moving content: a sticky header, a fixed toolbar or a card floating over a scroller. - 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-blur | How frosted the glass is. Default 14 px. |
data-tint | The glass colour. Default rgba(255,255,255,.22); try a dark one for dark pages. |
data-grain | How much grain, from 0 to 1. Default 0.5. |
data-autoscroll | A CSS selector for a scrolling element behind the panel, which then drifts up and down by itself until someone scrolls it. |
data-speed | Autoscroll speed. Default 40 pixels a second. |
Browsers without backdrop-filter get a plain, nearly opaque white panel instead.
<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><header data-frosted-glass-panel style="position:sticky; top:0">
<nav>…</nav>
</header>