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
- Paste the snippet just before
</head>. - Add
data-scroll-expand-frameto an element holding an image or video. It fills the frame, cropped to cover it. - Optionally add
data-captionto text inside that should fade up once the frame is full size.
Options
Add any of these attributes to the same element.
data-width | The frame’s starting width, as a share of the screen. Default 0.7. |
data-height | Its starting height, as a share of the screen. Default 0.55. |
data-radius | Starting corner radius in px. Default 24. |
data-zoom | The picture 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. |
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.
<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><div data-scroll-expand-frame>
<video src="reel.mp4" autoplay muted loop playsinline></video>
<h2 data-caption>Golden hour</h2>
</div>