Curtain image revealWeb · App
Target
Implement the "Parallax hero" effect in vanilla HTML/CSS/JS. Context: Background and foreground layers of a hero move at different speeds while the page scrolls. 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): - translateY (background): 0 → -20% of hero height - translateY (foreground): 0 → -60% of hero height - opacity (foreground): 1 → 0 - scroll range: 0% (hero top at scroller top) → 100% (hero fully scrolled out) - easing: linear Motion tokens (prefers-reduced-motion): - opacity (foreground): 1 → 0 - scroll range: 0% → 100% - easing: linear - note: no parallax displacement; layers scroll together 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 - layers move only with transform; no background-position animation - hero text remains readable at scroll 0
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-parallax-hero" data-fx-root>
<div class="fx-scroller" tabindex="0" aria-label="Scrollable page">
<section class="fx-hero">
<div class="fx-bg" aria-hidden="true"></div>
<div class="fx-fg"><div class="fx-h"></div><div class="fx-sub"></div></div>
</section>
<div class="fx-body"><div class="fx-p"></div><div class="fx-p"></div><div class="fx-p fx-short"></div><div class="fx-p"></div><div class="fx-p fx-short"></div></div>
</div>
</div>
demo.css
.fx-parallax-hero { position: relative; width: 100%; height: 100%; background: #fff; timeline-scope: --fx-page; }
.fx-parallax-hero .fx-scroller { position: absolute; inset: 0; overflow-y: auto; scroll-timeline: --fx-page block; }
.fx-parallax-hero .fx-hero { position: relative; height: 70%; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.fx-parallax-hero .fx-bg { position: absolute; inset: -20% 0 0 0; background: linear-gradient(135deg, #c7d2fe, #4f46e5 60%, #312e81); will-change: transform; }
.fx-parallax-hero .fx-fg { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; width: 60%; will-change: transform; }
.fx-parallax-hero .fx-h { height: 22px; width: 70%; border-radius: 6px; background: rgba(255,255,255,.95); }
.fx-parallax-hero .fx-sub { height: 10px; width: 45%; border-radius: 5px; background: rgba(255,255,255,.6); }
.fx-parallax-hero .fx-body { display: flex; flex-direction: column; gap: 12px; padding: 24px; min-height: 160%; }
.fx-parallax-hero .fx-p { height: 10px; border-radius: 4px; background: var(--fx-muted, #e4e4e7); }
.fx-parallax-hero .fx-short { width: 65%; }
@supports (animation-timeline: scroll()) {
.fx-parallax-hero.use-css .fx-bg { animation: fx-ph-bg linear both; animation-timeline: --fx-page; animation-range: 0 70%; }
.fx-parallax-hero.use-css .fx-fg { animation: fx-ph-fg linear both; animation-timeline: --fx-page; animation-range: 0 70%; }
@keyframes fx-ph-bg { to { transform: translateY(-20%); } }
@keyframes fx-ph-fg { to { transform: translateY(-60%); opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.fx-parallax-hero.use-css .fx-bg { animation: none; }
@keyframes fx-ph-fg { to { opacity: 0; } }
}
}
demo.js
export default function mount(root) {
const scroller = root.querySelector('.fx-scroller'), bg = root.querySelector('.fx-bg'), fg = root.querySelector('.fx-fg'), hero = root.querySelector('.fx-hero');
const supported = CSS.supports('animation-timeline: scroll()');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
root.dataset.fxMode = supported ? 'css' : 'js-fallback';
const onScroll = () => {
const p = Math.min(1, scroller.scrollTop / hero.offsetHeight);
bg.style.transform = reduced ? '' : `translateY(${-20 * p}%)`;
fg.style.transform = reduced ? '' : `translateY(${-60 * p}%)`;
fg.style.opacity = String(1 - 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); bg.style.transform = fg.style.transform = fg.style.opacity = ''; },
};
}
demo.gsap.js
export default function mount(root) {
const scroller = root.querySelector('.fx-scroller'), bg = root.querySelector('.fx-bg'), fg = root.querySelector('.fx-fg'), hero = root.querySelector('.fx-hero');
let tl;
return {
play() {
const { gsap, ScrollTrigger } = window; if (!gsap || !ScrollTrigger) { root.dataset.fxMode = 'gsap-missing'; return; }
gsap.registerPlugin(ScrollTrigger); root.dataset.fxMode = 'gsap';
tl = gsap.timeline({ scrollTrigger: { scroller, trigger: hero, start: 'top top', end: 'bottom top', scrub: true } });
tl.to(bg, { yPercent: -20, ease: 'none' }, 0).to(fg, { yPercent: -60, opacity: 0, ease: 'none' }, 0);
},
stop() { tl?.scrollTrigger?.kill(); tl?.kill(); tl = undefined; gsap?.set?.([bg, fg], { clearProps: 'all' }); },
};
}
Inspired by gsap.com — the classic ScrollTrigger scrub parallax. The preview is an original re-creation; the effect name belongs to its author.
Named `scroll-timeline` on the inner scroller with `timeline-scope` on the root; `animation-range: 0 70%` maps the hero's own height (70% of the stage) to progress 0→1. GSAP version: one timeline, `scrub: true`, both tweens at position 0. Use `yPercent` so the distances stay relative to layer height.
Added 2026-11-09 · Report an issue