Progressive Blur
The edges of a scrolling list melt away. Content gets blurrier the closer it comes to the top or bottom, building up in smooth steps rather than a hard line, so it looks like it slides out of focus. Each edge only shows while there is more to scroll that way.
Morning swimSaved to your trips
Harbour walkSaved to your trips
Lemon tartSaved to your trips
Fern gullySaved to your trips
Berry pickingSaved to your trips
Night marketSaved to your trips
Morning swimSaved to your trips
Harbour walkSaved to your trips
Lemon tartSaved to your trips
Fern gullySaved to your trips
Berry pickingSaved to your trips
Night marketSaved to your trips
How to use it
- Paste the snippet just before
</head>. - Add
data-progressive-blurto the scrolling element itself (the one withoverflow:autoand a height). Its content scrolls under the blur. - Put any padding on the content inside rather than on the scroller, so the blur reaches right to the edges.
Options
Add any of these attributes to the same element.
data-edges | top, bottom or both. Default both. |
data-size | How far in from the edge the blur reaches. Default 96 px. |
data-blur | The blur right at the edge. Default 16 px. |
data-fade | A colour to fade into as well, usually your background, e.g. #fff. Default none. |
data-autoscroll | Drift up and down by itself until someone scrolls it. Handy for demos. |
data-speed | Autoscroll speed. Default 40 pixels a second. |
Browsers without backdrop-filter simply show the content unblurred.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Progressive Blur: the edges of a scrolling area melt away. Content gets
// blurrier the closer it comes to the top or bottom edge, in smooth steps
// rather than a hard line, so it looks like it is sliding out of focus. Each
// edge only appears while there is more to scroll that way.
// Usage: <div data-progressive-blur style="height:400px; overflow:auto">…</div>
// Put it on the scrolling element itself; its children scroll under the blur.
// Optional: data-edges (top, bottom or both, default both), data-size (how
// deep the blur reaches, px, default 96), data-blur (blur right at the edge,
// px, default 16), data-fade (a colour to fade into as well, e.g. #fff,
// default none), data-autoscroll (drift up and down by itself while nobody
// is scrolling; handy for demos), data-speed (autoscroll pixels per second,
// default 40).
var DEFAULTS = { size: 96, blur: 16, layers: 6, speed: 40 };
var css =
'.pgbl-edge{display:block;position:-webkit-sticky;position:sticky;z-index:2;pointer-events:none;transition:opacity .35s}' +
'.pgbl-edge>i{position:absolute;inset:0}' +
'.pgbl-top{top:0}' +
'.pgbl-bottom{bottom:0}' +
'.pgbl-off{opacity:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// One edge: a stack of layers, each blurring a bit more, each masked to an overlapping
// band so the strength builds up gradually towards the edge.
function edge(where, size, blur, fade, n) {
var box = document.createElement('div');
box.className = 'pgbl-edge pgbl-' + where;
box.setAttribute('aria-hidden', 'true');
box.style.height = size + 'px';
if (where === 'top') box.style.marginBottom = -size + 'px';
else box.style.marginTop = -size + 'px';
var dir = where === 'top' ? 'to top' : 'to bottom';
var step = 100 / (n + 2);
for (var i = 0; i < n; i++) {
var layer = document.createElement('i');
var b = blur / Math.pow(2, n - 1 - i);
var a = (i * step).toFixed(2), s = ((i + 1) * step).toFixed(2);
var c = ((i + 2) * step).toFixed(2), e = ((i + 3) * step).toFixed(2);
var mask = i === n - 1
? 'linear-gradient(' + dir + ',transparent ' + a + '%,#000 ' + s + '%)'
: 'linear-gradient(' + dir + ',transparent ' + a + '%,#000 ' + s + '%,#000 ' + c + '%,transparent ' + e + '%)';
layer.style.webkitBackdropFilter = layer.style.backdropFilter = 'blur(' + b.toFixed(2) + 'px)';
layer.style.webkitMaskImage = layer.style.maskImage = mask;
box.appendChild(layer);
}
if (fade) {
var tint = document.createElement('i');
tint.style.background = 'linear-gradient(' + dir + ',transparent,' + fade + ')';
box.appendChild(tint);
}
return box;
}
function setup(el) {
var d = el.dataset;
var edges = d.edges || 'both';
var size = +d.size || DEFAULTS.size;
var blur = d.blur !== undefined ? +d.blur : DEFAULTS.blur;
var speed = +d.speed || DEFAULTS.speed;
var top = null, bottom = null;
if (edges !== 'bottom') {
top = edge('top', size, blur, d.fade, DEFAULTS.layers);
el.insertBefore(top, el.firstChild);
}
if (edges !== 'top') {
bottom = edge('bottom', size, blur, d.fade, DEFAULTS.layers);
el.appendChild(bottom);
}
// Show an edge only while there is more to scroll that way.
function update() {
var max = el.scrollHeight - el.clientHeight;
if (top) top.classList.toggle('pgbl-off', el.scrollTop < 4);
if (bottom) bottom.classList.toggle('pgbl-off', el.scrollTop > max - 4);
}
el.addEventListener('scroll', update, { passive: true });
if (window.ResizeObserver) new ResizeObserver(update).observe(el);
update();
var auto = d.autoscroll !== undefined && d.autoscroll !== 'false';
if (!auto || reduced()) return;
// Autoscroll: drift down to the end and back, pausing whenever someone scrolls or touches it.
var dir = 1, wait = 0, pos = el.scrollTop, last = 0, setting = false;
function pause() { wait = performance.now() + 2500; }
['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) {
el.addEventListener(t, pause, { passive: true });
});
el.addEventListener('scroll', function () {
if (!setting) { pos = el.scrollTop; pause(); }
setting = false;
}, { passive: true });
var 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 = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
// Ease off near each end rather than bumping into it.
var room = Math.min(pos, max - pos);
var v = speed * Math.min(1, 0.25 + room / 80);
pos += dir * v * dt / 1000;
if (pos >= max) { pos = max; dir = -1; wait = now + 1000; }
if (pos <= 0) { pos = 0; dir = 1; wait = now + 1000; }
setting = true;
el.scrollTop = pos;
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); }
else timer.pause();
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-progressive-blur]').forEach(function (el) {
if (el._pgbl) return;
el._pgbl = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-progressive-blur data-fade="#fff" style="height:400px; overflow:auto">
<ul>…</ul>
</div>