From ae531116b7dbb7a0fd193c7cccdff31dfec6c0f6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 14:45:30 -0400 Subject: [PATCH] fix(parallax): animate background-position-y directly so SSR parallax works pre-WASM --- DeepDrftPublic/Components/App.razor | 1 + .../Components/ParallaxImage.razor.cs | 10 +- .../Components/ParallaxImage.razor.css | 87 -------------- .../Interop/parallax/parallax.ts | 18 ++- .../wwwroot/css/parallax.css | 110 ++++++++++++++++++ 5 files changed, 130 insertions(+), 96 deletions(-) delete mode 100644 DeepDrftShared.Client/Components/ParallaxImage.razor.css create mode 100644 DeepDrftShared.Client/wwwroot/css/parallax.css diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor index aa85458..f27747e 100644 --- a/DeepDrftPublic/Components/App.razor +++ b/DeepDrftPublic/Components/App.razor @@ -11,6 +11,7 @@ + diff --git a/DeepDrftShared.Client/Components/ParallaxImage.razor.cs b/DeepDrftShared.Client/Components/ParallaxImage.razor.cs index b114191..cfa0488 100644 --- a/DeepDrftShared.Client/Components/ParallaxImage.razor.cs +++ b/DeepDrftShared.Client/Components/ParallaxImage.razor.cs @@ -87,11 +87,11 @@ public abstract class ParallaxImageBase : ComponentBase, IAsyncDisposable private string? _handle; // --parallax-from/--parallax-to are inherited custom properties read by the - // CSS @keyframes parallax-pan (scroll-driven animation on .parallax-window). - // They encode ParallaxSpeed and InvertDirection. --parallax-pos itself is - // never set inline — an inline value would beat the CSS animation in the - // cascade and defeat the pre-WASM scroll-driven parallax. JS sets it inline - // only after WASM boots, transparently taking over (inline > animation). + // CSS @keyframes parallax-pan, which animates background-position-y on each + // .layer (scroll-driven, pre-WASM). They encode ParallaxSpeed and + // InvertDirection. After WASM boots, parallax.js sets data-parallax-active to + // cancel that animation and drives background-position-y on the layers + // directly — a clean handoff with no competing writers. private string ParallaxVars() { var end = (int)Math.Round(ParallaxSpeed * 100); diff --git a/DeepDrftShared.Client/Components/ParallaxImage.razor.css b/DeepDrftShared.Client/Components/ParallaxImage.razor.css deleted file mode 100644 index be7f4bb..0000000 --- a/DeepDrftShared.Client/Components/ParallaxImage.razor.css +++ /dev/null @@ -1,87 +0,0 @@ -@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 700ms 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; - } -} diff --git a/DeepDrftShared.Client/Interop/parallax/parallax.ts b/DeepDrftShared.Client/Interop/parallax/parallax.ts index 5a708d1..bd40a6f 100644 --- a/DeepDrftShared.Client/Interop/parallax/parallax.ts +++ b/DeepDrftShared.Client/Interop/parallax/parallax.ts @@ -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(':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); diff --git a/DeepDrftShared.Client/wwwroot/css/parallax.css b/DeepDrftShared.Client/wwwroot/css/parallax.css new file mode 100644 index 0000000..f656611 --- /dev/null +++ b/DeepDrftShared.Client/wwwroot/css/parallax.css @@ -0,0 +1,110 @@ +/* + * ParallaxImage styles — served as a plain static asset via + * _content/DeepDrftShared.Client/css/parallax.css. + * + * Why global, not scoped (.razor.css): + * DeepDrftShared.Client is a WASM RCL referenced only by DeepDrftPublic.Client, + * not by the DeepDrftPublic server host. Blazor merges scoped-CSS bundles only + * from RCLs the *host* references, so this component's scoped bundle is absent + * from DeepDrftPublic.styles.css and never reaches the SSR first paint — it + * arrives only after WASM boots. Structural rules AND the scroll-driven + * animation must be present at first paint, so they live here as global CSS, + * delivered as a static web asset regardless of which project references the RCL. + * + * ParallaxImage is the sole producer of .parallax-window / .layer, so unscoped + * class selectors are unambiguous. + */ + +.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-x: 50%; + background-position-y: var(--parallax-from, 0%); +} + +.layer-1 { + opacity: 1; +} + +.layer-2 { + opacity: 0; + transition: opacity 700ms ease; +} + +.parallax-window:hover .layer-2 { + opacity: 1; +} + +/* + * Scroll-driven parallax, present at SSR first paint (no JS, no custom-property + * inheritance chain): + * + * Before WASM: + * The view() timeline animates background-position-y on each .layer directly, + * from --parallax-from to --parallax-to (both percentages set inline on + * .parallax-window by the component, encoding ParallaxSpeed/InvertDirection). + * The layer pans as the window scrolls through the viewport — correct from + * first paint. + * + * After WASM: + * JS sets data-parallax-active on .parallax-window, which cancels the CSS + * animation (animation: none). JS then drives background-position-y via the + * scroll listener. One writer at a time — the two never compete. + * + * prefers-reduced-motion: + * animation: none → static image at --parallax-from. JS also skips its + * scroll listener (see parallax.ts), so the image stays put. + */ +@supports (animation-timeline: view()) { + @keyframes parallax-pan { + from { background-position-y: var(--parallax-from, 0%); } + to { background-position-y: var(--parallax-to, 0%); } + } + + /* Animate layers directly — no --parallax-pos inheritance chain. + .parallax-window uses overflow: hidden, which establishes a block + formatting context but NOT a scroll container (that needs overflow: + scroll/auto), so view() correctly resolves to the root scroller. */ + .parallax-window > .layer { + animation: parallax-pan linear both; + animation-timeline: view(); + } + + /* JS takes over on register: cancel the CSS animation so the two writers + to background-position-y never compete. */ + .parallax-window[data-parallax-active] > .layer { + animation: none; + } + + @media (prefers-reduced-motion: reduce) { + .parallax-window > .layer { + animation: none; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .layer-2 { + transition-duration: 0ms; + } +}