← Scroll

Section Uncover

The section you’re reading lifts away like a sheet of paper, and the next one is uncovered from beneath it, already waiting in place. It starts a little sunk and shaded and rises to meet you as the sheet clears it.

Scroll inside ↓

Scroll onThis section lifts away…
Underneath…and this one was waiting below.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-section-uncover to the section to be uncovered. The section just before it is the sheet that lifts away, so give it a background.
  3. Keep both sections in the same parent. The uncovered one works best no taller than the screen.

Options

Add any of these attributes to the same element.

data-depthHow far its content starts sunk, as a scale. Default 0.92; 1 for none.
data-shadeHow dark it starts, from 0 to 1. Default 0.45; 0 for none.
data-shadowThe shadow under the lifting sheet, as a CSS box-shadow, or none.

It pins with position: sticky, so nothing around it can have overflow: hidden. The rise is a transform on the section’s children, so don’t give them one of your own. With reduced motion it is still uncovered, just without rising. 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 () {
    // Section Uncover: the section before this one scrolls away like a sheet
    // lifting off, and this section is uncovered from beneath it, waiting in
    // place. It starts a little sunk and shaded, and rises to full brightness
    // as the sheet clears it.
    // Usage: <section class="hero">…</section>
    //        <section data-section-uncover>…</section>
    // The section before it needs a background (it is the sheet that slides
    // away), and both need to sit in the same parent. The uncovered section
    // works best no taller than the screen.
    // Optional: data-depth (how far the content starts sunk, as a scale,
    // default 0.92; 1 for none), data-shade (how dark it starts, 0 to 1,
    // default 0.45; 0 for none), data-shadow (the shadow under the lifting
    // sheet, any CSS box-shadow, or "none").
    // The sinking and rising is a transform on the section's children, so
    // don't put a transform of your own on them.
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { depth: 0.92, shade: 0.45, shadow: '0 18px 40px -8px rgba(15,10,40,.45)' };

    var css =
      '.secu{position:sticky!important;bottom:0;z-index:0;overflow:hidden}' +
      '.secu>:not(.secu-shade){transform:translate3d(0,var(--secu-y,0),0) scale(var(--secu-s,1))}' +
      '.secu-shade{position:absolute;inset:0;pointer-events:none;background:#0b0820}' +
      '.secu-sheet{position:relative;z-index:1}';
    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) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    function ease(t) { return 1 - Math.pow(1 - t, 3); }

    function setup(el) {
      var d = el.dataset;
      var depth = Math.max(0.5, Math.min(1, num(d.depth, DEFAULTS.depth)));
      var shadeMax = Math.max(0, Math.min(1, num(d.shade, DEFAULTS.shade)));
      var sheet = el.previousElementSibling;
      var box = scroller(el);

      el.classList.add('secu');
      if (sheet) {
        sheet.classList.add('secu-sheet');
        if (d.shadow !== 'none') sheet.style.boxShadow = d.shadow || DEFAULTS.shadow;
      }
      if (reduced()) return;   // still uncovered from beneath, just without the rising and shading

      var shade = document.createElement('div');
      shade.className = 'secu-shade';
      shade.setAttribute('aria-hidden', 'true');
      el.appendChild(shade);

      var queued = false, last = -1;

      function update() {
        queued = false;
        var viewTop = box ? box.getBoundingClientRect().top + box.clientTop : 0;
        var viewH = box ? box.clientHeight : window.innerHeight;
        var h = el.offsetHeight || 1;
        // How much of the section the sheet has cleared so far.
        var shown = sheet ? viewTop + viewH - sheet.getBoundingClientRect().bottom : h;
        var p = Math.max(0, Math.min(1, shown / Math.min(h, viewH)));
        if (Math.abs(p - last) < 0.001) return;
        last = p;
        var e = ease(p);
        el.style.setProperty('--secu-y', ((1 - e) * 24).toFixed(2) + 'px');
        el.style.setProperty('--secu-s', (depth + (1 - depth) * e).toFixed(4));
        shade.style.opacity = (shadeMax * (1 - e)).toFixed(3);
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }

      update();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', function () { last = -1; onScroll(); });
    }

    function init() {
      document.querySelectorAll('[data-section-uncover]').forEach(function (el) {
        if (el._secu) return;
        el._secu = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section class="hero">…</section>
<section data-section-uncover>
  <h2>Underneath</h2>
</section>