Curtain image revealWeb · App
Target
Implement the "Scroll zoom-out image" effect in vanilla HTML/CSS/JS. Context: An image starts slightly zoomed in and settles to 1:1 as it scrolls into view, bound to scroll position. Trigger: the page scrolls; progress is bound to scroll position, not time. Timeline: scroll-progress-based (map scroll position to animation progress; no fixed duration). Motion tokens (normal): - scale: 1.18 → 1 - opacity: 0.4 → 1 - scroll range: entry 0% (image top enters) → cover 45% - easing: linear Motion tokens (prefers-reduced-motion): - opacity: 0.4 → 1 - scroll range: entry 0% → cover 45% - easing: linear Technique: prefer CSS `animation-timeline: scroll()/view()` inside `@supports (animation-timeline: scroll())`, with a JS fallback; GSAP (ScrollTrigger where relevant) is acceptable as an alternative. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - image container clips overflow so the zoom never changes layout - progress reverses when scrolling back up
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-scroll-zoom-image" data-fx-root>
<div class="fx-scroller" tabindex="0" aria-label="Scrollable page">
<div class="fx-spacer"><div class="fx-p"></div><div class="fx-p fx-short"></div><div class="fx-p"></div></div>
<figure class="fx-frame"><div class="fx-img" role="img" aria-label="Placeholder image"></div></figure>
<div class="fx-spacer"><div class="fx-p"></div><div class="fx-p fx-short"></div><div class="fx-p"></div><div class="fx-p"></div></div>
</div>
</div>
demo.css
.fx-scroll-zoom-image { position: relative; width: 100%; height: 100%; background: #fff; }
.fx-scroll-zoom-image .fx-scroller { position: absolute; inset: 0; overflow-y: auto; }
.fx-scroll-zoom-image .fx-spacer { display: flex; flex-direction: column; gap: 12px; padding: 24px; min-height: 90%; }
.fx-scroll-zoom-image .fx-p { height: 10px; border-radius: 4px; background: var(--fx-muted, #e4e4e7); }
.fx-scroll-zoom-image .fx-short { width: 60%; }
.fx-scroll-zoom-image .fx-frame { margin: 0 24px; height: 55%; border-radius: 12px; overflow: hidden; }
.fx-scroll-zoom-image .fx-img { width: 100%; height: 100%; background: linear-gradient(135deg, #a5b4fc, #4f46e5 55%, #1e1b4b); transform: scale(1.18); opacity: .4; will-change: transform, opacity; }
@supports (animation-timeline: view()) {
.fx-scroll-zoom-image.use-css .fx-img { animation: fx-szi linear both; animation-timeline: view(); animation-range: entry 0% cover 45%; }
@keyframes fx-szi { to { transform: scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .fx-scroll-zoom-image .fx-img { transform: none; } @keyframes fx-szi { to { opacity: 1; } } }
}
demo.js
export default function mount(root) {
const scroller = root.querySelector('.fx-scroller'), frame = root.querySelector('.fx-frame'), img = root.querySelector('.fx-img');
const supported = CSS.supports('animation-timeline: view()');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
root.dataset.fxMode = supported ? 'css' : 'js-fallback';
const onScroll = () => {
const sr = scroller.getBoundingClientRect(), fr = frame.getBoundingClientRect();
const entered = sr.bottom - fr.top; // px of frame below scroller top edge
const p = Math.max(0, Math.min(1, entered / (sr.height * 0.45 + fr.height * 0.45)));
img.style.transform = reduced ? 'none' : `scale(${1.18 - 0.18 * p})`;
img.style.opacity = String(0.4 + 0.6 * p);
};
return {
play() { if (supported) root.classList.add('use-css'); else { scroller.addEventListener('scroll', onScroll, { passive: true }); onScroll(); } },
stop() { root.classList.remove('use-css'); scroller.removeEventListener('scroll', onScroll); img.style.transform = img.style.opacity = ''; },
};
}
demo.gsap.js
export default function mount(root) {
const scroller = root.querySelector('.fx-scroller'), frame = root.querySelector('.fx-frame'), img = root.querySelector('.fx-img');
let tween;
return {
play() {
const { gsap, ScrollTrigger } = window; if (!gsap || !ScrollTrigger) { root.dataset.fxMode = 'gsap-missing'; return; }
gsap.registerPlugin(ScrollTrigger); root.dataset.fxMode = 'gsap';
tween = gsap.fromTo(img, { scale: 1.18, opacity: .4 }, { scale: 1, opacity: 1, ease: 'none', scrollTrigger: { scroller, trigger: frame, start: 'top bottom', end: 'center 55%', scrub: true } });
},
stop() { tween?.scrollTrigger?.kill(); tween?.kill(); tween = undefined; img.style.transform = img.style.opacity = ''; },
};
}
Inspired by scroll-driven-animations.style — view() timeline image reveals. The preview is an original re-creation; the effect name belongs to its author.
`view()` needs no named timeline: it reads the element's own position inside the nearest scroller. `animation-range: entry 0% cover 45%` = start when the top edge appears, finish when the element has covered 45% of the scrollport.
Added 2026-11-09 · Report an issue