← Scroll

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 ↓

  1. 06:00Sunrise
  2. 07:00Coffee
  3. 08:00Deep work
  4. 09:00Long lunch
  5. 10:00A walk
  6. 11:00Ideas
  7. 12:00Make things
  8. 13:00Golden hour
  9. 14:00Sleep

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-centre-focus-scroll to a list. Each child is an item.
  3. 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-blurThe most blur, in px. Default 6.
data-fadeThe lowest opacity, from 0 to 1. Default 0.25.
data-scaleThe smallest size. Default 0.92.
data-rangeHow 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.

1 · Snippet
<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>
2 · Your HTML
<ol data-centre-focus-scroll style="padding: 40vh 0">
  <li>Sunrise</li>
  <li>Coffee</li>
  <li>Deep work</li>
</ol>