← Scroll

Scroll Expand Frame

A rounded picture, framed small in the middle of the screen, grows to fill the whole screen as it scrolls into view, its corners squaring off and the picture settling from a gentle zoom. It pins at full size for a moment and a caption fades up.

Scroll inside ↓

Golden hour

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scroll-expand-frame to an element holding an image or video. It fills the frame, cropped to cover it.
  3. Optionally add data-caption to text inside that should fade up once the frame is full size.

Options

Add any of these attributes to the same element.

data-widthThe frame’s starting width, as a share of the screen. Default 0.7.
data-heightIts starting height, as a share of the screen. Default 0.55.
data-radiusStarting corner radius in px. Default 24.
data-zoomThe picture eases from this zoom down to 1 as it grows. Default 1.15; 1 for none.
data-lengthHow many screen heights it stays pinned at full size. Default 1.

The frame takes the width of its container and the height of the view. It pins with position: sticky, so nothing around it can have overflow: hidden. With reduced motion it’s left as you styled it. 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 () {
    // Scroll Expand Frame: a rounded image or video, framed small in the middle
    // of the screen, grows to fill the whole screen as it scrolls into view and
    // its corners square off. It pins while it fills the screen, and anything
    // marked data-caption inside fades up once it's full size.
    // Usage: <div data-scroll-expand-frame>
    //          <img src="hero.jpg" alt="…">
    //          <h2 data-caption>Golden hour</h2>
    //        </div>
    // The first image or video fills the frame (cropped to cover it).
    // Optional: data-width and data-height (the frame's starting size, as
    // shares of the screen, default 0.7 and 0.55), data-radius (starting corner
    // radius in px, default 24), data-zoom (the picture also eases from this
    // zoom down to 1 as it grows, default 1.15; 1 for none), data-length (how
    // many screen heights it stays pinned at full size, default 1).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { width: 0.7, height: 0.55, radius: 24, zoom: 1.15, length: 1 };

    var css =
      '.sef{position:relative}' +
      '.sef-pin{position:sticky;top:0;overflow:hidden}' +
      '.sef-frame{position:absolute!important;inset:0;overflow:hidden;margin:0!important;width:auto!important;height:auto!important;max-width:none!important;aspect-ratio:auto!important;border-radius:0!important}' +
      '.sef-media{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;transform-origin:50% 50%}' +
      '.sef-frame [data-caption]{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 setup(el) {
      var d = el.dataset;
      var w0 = Math.min(1, num(d.width, DEFAULTS.width));
      var h0 = Math.min(1, num(d.height, DEFAULTS.height));
      var r0 = num(d.radius, DEFAULTS.radius);
      var zoom = num(d.zoom, DEFAULTS.zoom);
      var length = Math.max(0, num(d.length, DEFAULTS.length));
      var media = el.querySelector('img,video,picture,svg,canvas');
      var captions = Array.prototype.slice.call(el.querySelectorAll('[data-caption]'));
      var box = scroller(el);

      var section = document.createElement('div');
      section.className = 'sef';
      var pin = document.createElement('div');
      pin.className = 'sef-pin';
      el.parentNode.insertBefore(section, el);
      section.appendChild(pin);
      pin.appendChild(el);
      el.classList.add('sef-frame');
      if (media) media.classList.add('sef-media');
      captions.forEach(function (c) { c.style.opacity = 0; });

      var viewH = 0, queued = false, last = -1, shown = false;

      function measure() {
        viewH = box ? box.clientHeight : window.innerHeight;
        pin.style.height = viewH + 'px';
        section.style.height = viewH * (1 + length) + 'px';
        last = -1;
      }

      function captionsIn(on) {
        if (on === shown) return;
        shown = on;
        anime.utils.remove(captions);
        anime.animate(captions, on
          ? { opacity: [0, 1], y: [16, 0], duration: 700, delay: anime.stagger(90), ease: 'outQuart' }
          : { opacity: 0, y: 8, duration: 300, ease: 'out(2)' });
      }

      function update() {
        queued = false;
        var top = 0;
        if (box) top = box.getBoundingClientRect().top + box.clientTop;
        var y = section.getBoundingClientRect().top - top;
        // Grows from when the frame is most of the way up the screen until it
        // pins (with half a screen to spare if there's room), then holds.
        var from = viewH * 0.75, to = -viewH * Math.min(0.5, length * 0.5);
        var p = Math.max(0, Math.min(1, (from - y) / (from - to)));
        if (Math.abs(p - last) < 0.0005) return;
        last = p;
        var e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
        var ix = ((1 - w0) / 2) * (1 - e) * 100, iy = ((1 - h0) / 2) * (1 - e) * 100;
        var r = r0 * (1 - e);
        el.style.clipPath = e >= 1 ? 'none' : 'inset(' + iy.toFixed(2) + '% ' + ix.toFixed(2) + '% round ' + r.toFixed(1) + 'px)';
        if (media && zoom !== 1) media.style.transform = 'scale(' + (zoom + (1 - zoom) * e).toFixed(4) + ')';
        captionsIn(p >= 0.92);
      }

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

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

    function init() {
      if (reduced()) {
        // No motion: the picture just sits full size with its captions showing.
        return;
      }
      document.querySelectorAll('[data-scroll-expand-frame]').forEach(function (el) {
        if (el._sef) return;
        el._sef = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-scroll-expand-frame>
  <video src="reel.mp4" autoplay muted loop playsinline></video>
  <h2 data-caption>Golden hour</h2>
</div>