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;
+ }
+}