fix(parallax): animate background-position-y directly so SSR parallax works pre-WASM

This commit is contained in:
daniel-c-harvey
2026-06-11 14:45:30 -04:00
parent 9d7f2ff003
commit ae531116b7
5 changed files with 130 additions and 96 deletions
@@ -2,8 +2,11 @@
* parallax - scroll-driven background-position panning for ParallaxImage.
*
* Single Responsibility: own the parallax math and scroll/observer lifecycle.
* Blazor owns the component lifecycle and calls register/unregister; this module
* writes only the `--parallax-pos` CSS custom property — never concrete style.
* Blazor owns the component lifecycle and calls register/unregister. When the
* IntersectionObserver fires and JS attaches the scroll listener, this module
* sets data-parallax-active and immediately primes background-position-y —
* atomically cancelling the pre-WASM CSS animation and writing the correct
* position in the same turn, so there is no flash at the handoff.
*/
interface RegisterOptions {
@@ -44,7 +47,12 @@ function applyParallax(handle: Handle): void {
progress = clamp(progress, 0, 1);
const pos = progress * clamp(options.speed, 0, 1) * 100;
element.style.setProperty('--parallax-pos', `${pos}%`);
// Write background-position-y on each layer directly — the same property the
// pre-WASM CSS animation drives (now cancelled via data-parallax-active).
const layers = element.querySelectorAll<HTMLElement>(':scope > .layer');
for (const layer of layers) {
layer.style.backgroundPositionY = `${pos}%`;
}
}
function attachScrollListener(handle: Handle): void {
@@ -60,7 +68,8 @@ function attachScrollListener(handle: Handle): void {
handle.scrollListener = listener;
window.addEventListener('scroll', listener, { passive: true });
// Prime position immediately so entry isn't a frame behind the first scroll.
// Cancel CSS animation and prime position atomically — no gap where neither drives.
handle.element.setAttribute('data-parallax-active', '');
applyParallax(handle);
}
@@ -140,6 +149,7 @@ export function unregister(handleId: string): void {
if (!handle) return;
detachScrollListener(handle);
handle.element.removeAttribute('data-parallax-active');
handle.observer.disconnect();
handle.resizeObserver?.disconnect();
handles.delete(handleId);