@property --parallax-pos { syntax: ''; inherits: true; initial-value: 0%; } .parallax-window { position: relative; overflow: hidden; height: var(--window-height, 300px); width: 100%; } .parallax-window.full-width { width: 100vw; position: relative; left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw; } .layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-repeat: no-repeat; background-position: 50% var(--parallax-pos, 50%); } .layer-1 { opacity: 1; } .layer-2 { opacity: 0; transition: opacity 400ms ease; } .parallax-window:hover .layer-2 { opacity: 1; } /* * Cascade interaction for --parallax-pos: * * Before WASM (SSR / hydration): * @property initial-value: 0% * → CSS animation (view timeline) overrides → correct position from scroll * * After WASM (JS running): * JS element.style.setProperty('--parallax-pos', ...) [inline style] * → inline style beats animation → JS takes over seamlessly * * prefers-reduced-motion: * animation: none → @property initial-value 0% used → static image * JS also skips scroll listener */ @supports (animation-timeline: view()) { @keyframes parallax-pan { from { --parallax-pos: var(--parallax-from, 0%); } to { --parallax-pos: var(--parallax-to, 0%); } } .parallax-window { animation: parallax-pan linear both; animation-timeline: view(); } @media (prefers-reduced-motion: reduce) { .parallax-window { animation: none; } } } @media (prefers-reduced-motion: reduce) { .parallax-window { --parallax-pos: 0%; } .layer-2 { transition-duration: 0ms; } }