From 91566692f69da236a73374cf1855b8df8d690c6c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 11:57:43 -0400 Subject: [PATCH] fix(parallax): drive --parallax-pos via CSS scroll animation to kill SSR/hydration position pop --- .../Components/ParallaxImage.razor.cs | 24 ++++++++++-- .../Components/ParallaxImage.razor.css | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/DeepDrftShared.Client/Components/ParallaxImage.razor.cs b/DeepDrftShared.Client/Components/ParallaxImage.razor.cs index 1a844cf..b114191 100644 --- a/DeepDrftShared.Client/Components/ParallaxImage.razor.cs +++ b/DeepDrftShared.Client/Components/ParallaxImage.razor.cs @@ -78,7 +78,7 @@ public abstract class ParallaxImageBase : ComponentBase, IAsyncDisposable [Parameter] public string? Class { get; set; } protected ElementReference WindowRef; - protected string WindowStyle { get; private set; } = "--window-height: 300px; --parallax-pos: 0%;"; + protected string WindowStyle { get; private set; } = "--window-height: 300px; --parallax-from: 0%; --parallax-to: 50%;"; private bool AspectRatioMode => WindowHeight is null && NaturalWidth is > 0 && NaturalHeight is > 0; @@ -86,11 +86,27 @@ public abstract class ParallaxImageBase : ComponentBase, IAsyncDisposable private IJSObjectReference? _module; 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). + private string ParallaxVars() + { + var end = (int)Math.Round(ParallaxSpeed * 100); + end = Math.Max(1, end); // floor at 1% so the property is always valid + return InvertDirection + ? $"--parallax-from: {end}%; --parallax-to: 0%;" + : $"--parallax-from: 0%; --parallax-to: {end}%;"; + } + protected override void OnParametersSet() { + var pv = ParallaxVars(); if (WindowHeight != null) { - WindowStyle = $"--window-height: {WindowHeight}; --parallax-pos: 0%;"; + WindowStyle = $"--window-height: {WindowHeight}; {pv}"; } else if (AspectRatioMode) { @@ -99,11 +115,11 @@ public abstract class ParallaxImageBase : ComponentBase, IAsyncDisposable // aspect-ratio: W / (H * fraction) → window is WindowHeightFraction // of the image's full display height at container width. var h = Math.Max(1, (int)Math.Round(NaturalHeight!.Value * WindowHeightFraction)); - WindowStyle = $"height: auto; aspect-ratio: {NaturalWidth!.Value} / {h}; --parallax-pos: 0%;"; + WindowStyle = $"height: auto; aspect-ratio: {NaturalWidth!.Value} / {h}; {pv}"; } else { - WindowStyle = "--window-height: 300px; --parallax-pos: 0%;"; + WindowStyle = $"--window-height: 300px; {pv}"; } } diff --git a/DeepDrftShared.Client/Components/ParallaxImage.razor.css b/DeepDrftShared.Client/Components/ParallaxImage.razor.css index 25283a3..f166a60 100644 --- a/DeepDrftShared.Client/Components/ParallaxImage.razor.css +++ b/DeepDrftShared.Client/Components/ParallaxImage.razor.css @@ -1,3 +1,9 @@ +@property --parallax-pos { + syntax: ''; + inherits: true; + initial-value: 0%; +} + .parallax-window { position: relative; overflow: hidden; @@ -37,6 +43,39 @@ 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%;