Centre Focus Scroll
Items sharpen as they reach the middle of the screen and blur, fade and shrink a little as they move away from it, so the eye always lands on the one in the centre, like lyrics in a music app.
Scroll inside ↓
- 06:00Sunrise
- 07:00Coffee
- 08:00Deep work
- 09:00Long lunch
- 10:00A walk
- 11:00Ideas
- 12:00Make things
- 13:00Golden hour
- 14:00Sleep
How to use it
- Paste the snippet just before
</head>. - Add
data-centre-focus-scrollto a list. Each child is an item. - Pad the top and bottom of the list so the first and last items can reach the middle.
Options
Add any of these attributes to the same element.
data-blur | The most blur, in px. Default 6. |
data-fade | The lowest opacity, from 0 to 1. Default 0.25. |
data-scale | The smallest size. Default 0.92. |
data-range | How far from the centre an item is fully out of focus, as a share of half the view. Default 0.9. |
data-axis="x" | For a sideways scrolling row: focus on the horizontal centre. |
The item in focus gets the class cfs-focus. With reduced motion every item stays sharp. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Centre Focus Scroll: the items in a list sharpen as they reach the middle
// of the screen and blur, fade and shrink a little as they move away from
// it, so the eye always lands on the one in the centre.
// Usage: <div data-centre-focus-scroll>
// <p>One</p><p>Two</p><p>Three</p> (each child is an item)
// </div>
// Optional: data-blur (the most blur, px, default 6), data-fade (the least
// opacity, 0 to 1, default 0.25), data-scale (the smallest size, default
// 0.92), data-range (how far from the centre an item is fully out of
// focus, as a share of half the view, default 0.9), data-axis="x" (for a
// sideways scrolling row: focus on the horizontal centre).
// Items in focus get the class cfs-focus.
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { blur: 6, fade: 0.25, scale: 0.92, range: 0.9 };
var css = '.cfs-item{will-change:filter,opacity,transform;transform-origin:50% 50%}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function scroller(el, x) {
for (var p = x ? el : el.parentElement; p && p !== document.body; p = p.parentElement) {
var cs = getComputedStyle(p);
var o = x ? cs.overflowX : cs.overflowY;
if (o === 'auto' || o === 'scroll') return p;
}
return null;
}
function setup(el) {
var d = el.dataset;
var blur = Math.max(0, num(d.blur, DEFAULTS.blur));
var fade = Math.max(0, Math.min(1, num(d.fade, DEFAULTS.fade)));
var scale = Math.max(0.1, Math.min(1, num(d.scale, DEFAULTS.scale)));
var range = Math.max(0.1, num(d.range, DEFAULTS.range));
var x = d.axis === 'x';
var box = scroller(el, x);
var items = Array.prototype.slice.call(el.children);
items.forEach(function (it) { it.classList.add('cfs-item'); });
var queued = false;
function update() {
queued = false;
var start, size;
if (box) {
var b = box.getBoundingClientRect();
start = x ? b.left + box.clientLeft : b.top + box.clientTop;
size = x ? box.clientWidth : box.clientHeight;
} else {
start = 0;
size = x ? window.innerWidth : window.innerHeight;
}
var mid = start + size / 2, half = (size / 2) * range, best = -1, bestT = 0.25;
for (var i = 0; i < items.length; i++) {
var r = items[i].getBoundingClientRect();
var c = x ? r.left + r.width / 2 : r.top + r.height / 2;
var t = Math.min(1, Math.abs(c - mid) / half);
var e = t * t * (3 - 2 * t); // smoothstep: a clear sweet spot in the middle
var s = items[i].style;
s.filter = e < 0.01 ? '' : 'blur(' + (blur * e).toFixed(2) + 'px)';
s.opacity = (1 - (1 - fade) * e).toFixed(3);
s.transform = e < 0.01 ? '' : 'scale(' + (1 - (1 - scale) * e).toFixed(4) + ')';
if (t < bestT) { bestT = t; best = i; }
}
// Only the item nearest the centre counts as in focus.
for (var j = 0; j < items.length; j++) items[j].classList.toggle('cfs-focus', j === best);
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
update();
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
if (window.ResizeObserver) new ResizeObserver(onScroll).observe(el);
}
function init() {
if (reduced()) return; // no motion: every item stays sharp
document.querySelectorAll('[data-centre-focus-scroll]').forEach(function (el) {
if (el._cfs) return;
el._cfs = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><ol data-centre-focus-scroll style="padding: 40vh 0">
<li>Sunrise</li>
<li>Coffee</li>
<li>Deep work</li>
</ol>