Files
deepdrft/product-notes/parallax-image-component.md
T
daniel-c-harvey b7b5933b25 docs(parallax): fold in resolved JS-placement and direction decisions
Resolve two open questions in the ParallaxImage spec: TS toolchain
co-located in DeepDrftShared.Client (Interop/parallax -> wwwroot/js), and
parallax direction exposed as the InvertDirection parameter. Update PLAN.md
7.1 constraint to reflect no remaining blockers.
2026-06-11 08:48:57 -04:00

24 KiB

ParallaxImage — reusable scroll-parallax image window (DeepDrftShared.Client)

Status: spec / both open decisions resolved (§11.1 JS placement, §11.2 direction) — ready for implementation. Author: product-designer. Date: 2026-06-11. Plan only — no code edits made by this doc.


1. Summary

A thin viewport-height container that reveals different portions of an image as the user scrolls — the classic CSS "parallax window." As the window scrolls up through the viewport, the image pans through the window faster than the page scrolls, so the window first shows the top of the image and, by the time it reaches the top of the viewport, shows the bottom. An optional second image crossfades in on hover (intended use: grayscale at rest, colour on hover).

It lives in DeepDrftShared.Client (the shared RCL) so both the public site and the CMS can use it. That placement drove the one load-bearing decision in this spec — where the JS/TS interop module ships from so both hosts can load it — now resolved (TS co-located in the shared RCL; see §6a).

The effect itself is well-trodden prior art (any number of agency landing pages; the canonical reference is the background-attachment: fixed parallax, which we deliberately do not use — it is broken on iOS Safari and janky on Android). We borrow the idiom and implement it the robust way: a scroll-driven background-position transform gated by an IntersectionObserver, matching the project's existing "math lives in TypeScript, lifecycle owned by Blazor" interop pattern (mirrors how SpectrumAnalyzer / AudioInteropService already work).


2. Component signature

DeepDrftShared.Client/Components/ParallaxImage.razor (+ .razor.cs, .razor.css).

Parameter Type Default Notes
Image1 string (required) Primary image URL. Shown at rest. Throws/renders nothing if null or empty.
Image2 string? null Optional hover image. When set, hovering crossfades Image1Image2; mouse-out fades back. Assumed same dimensions as Image1.
Alt1 string? null Alt text for Image1. See accessibility (§9).
Alt2 string? null Alt text for Image2.
WindowHeight string? see §4 Height of the parallax window. Accepts any CSS length ("300px", "40vh"). When null, resolves to the §4 fallback.
ImageWidth string "auto" background-size width.
ImageHeight string "auto" background-size height.
FullWidth bool false Critical. When true, the window stretches to 100vw, breaking out of parent padding/margins (§5b). When false, it is 100% of its parent.
ParallaxSpeed double 0.5 Multiplier: how much faster the image pans vs. scroll. 0 = static (no parallax), 1 = image moves with full scroll travel. Clamped to [0,1] (§4).
InvertDirection bool false When false (default): top of image visible on entry, bottom visible when the window reaches the top of the viewport (the corrected formula: 1 - rect.top/viewportH). When true: inverts — bottom of image visible on entry, top visible at viewport top. Passed through to the JS module via the register options object (§6b). See §3.
Class string? null Extra CSS classes on the outer window, per the project's existing component convention (Class is the house pass-through, see WaveformSeeker/SpectrumVisualizer).

The component does not take a separate sizing set for Image2 — same-dimensions assumption per the brief.


3. Parallax math

The window element's vertical position in the viewport drives background-position-y. The pan direction is a component parameter (InvertDirection, §2), passed through to the JS module via the register options object (§6b). Two formula variants, keyed on that flag:

// per scroll tick, for an in-view element:
rect          = element.getBoundingClientRect()
viewportH     = window.innerHeight

// progress: 0 when the window's top is at the bottom of the viewport,
//           1 when the window's top reaches the top of the viewport.
if (!invertDirection) {
    // DEFAULT — top of image visible on entry, bottom visible at viewport top.
    progress  = 1 - (rect.top / viewportH)
} else {
    // INVERTED — bottom of image visible on entry, top visible at viewport top
    //            (this is the brief's literal form).
    progress  = rect.top / viewportH
}
// clamp so we don't over-pan above/below the in-view band:
progress      = clamp(progress, 0, 1)
// pan the background from top (0%) toward bottom (100%) as progress grows:
backgroundPositionY = (progress * speed * 100) + "%"

Notes on the two variants vs. the brief's (element.top / viewport.height) * speed * 100%:

  • The brief's raw form (rect.top / viewportH) pans down as the element rises (because rect.top shrinks) — bottom visible on entry, top visible at the top of the viewport. That is now the InvertDirection = true branch.
  • The stated visual intent in the brief is the opposite — top visible on entry, bottom visible at the top of the viewport — produced by the 1 - (rect.top / viewportH) form. That is the default (InvertDirection = false) branch.
  • Resolved (Daniel, 2026-06-11): rather than hardcode either, the direction is exposed as the InvertDirection parameter so the consumer chooses. Default is the corrected (top-on-entry) form.
  • background-position: 50% Y% keeps horizontal centred; only Y is driven.
  • ParallaxSpeed is clamped [0,1]. Above 1 the image runs out of travel and clamps to the bottom edge early (visible "stick"); below 0 is meaningless. Clamp, don't error.
  • background-size must exceed the window height for there to be anything to pan — i.e. the image is taller than the window (that is the whole premise of the effect). If ImageHeight/natural height ≤ WindowHeight, there is no pan range; the component still renders (static image), it just has nothing to parallax. Not an error.

4. Sizing & defaults

  • WindowHeight default. The brief's ideal default is "50% of Image1 natural height, or 300px fallback if natural height is unknown." Natural height is not known at first server render (no image is loaded yet, and SSR has no DOM). Resolution:
    • Render with 300px (the safe fallback) as the initial CSS height.
    • On image load (onload of a hidden probe <img>, or the JS module reading naturalHeight once the background image decodes), if the consumer did not pass an explicit WindowHeight, recompute to naturalHeight * 0.5 and update a CSS custom property. This is a one-time post-load adjustment, gated on "consumer left it default."
    • If the consumer passed an explicit WindowHeight, never override it.
    • Trade-off: the post-load recompute can cause a layout shift (300px → computed) on first paint for default-height usages. Acceptable for the at-rest hero use; if CLS matters for a given placement, the consumer passes an explicit WindowHeight and the shift never happens. Document this in the component's XML doc comment.
  • ImageWidth / ImageHeight map directly to background-size. "auto" uses natural dimensions. A common real config will be ImageWidth="100%" with ImageHeight="auto" so the image is as wide as the (possibly full-width) window and tall enough to pan.

5. CSS architecture

5a. Scoped vs global

  • Scoped (.razor.css) for everything structural: the window box, the layered images, the crossfade transition, the --parallax-pos / --window-height custom properties. Blazor scoped CSS (b-{hash} attribute) keeps this from leaking into either host. This is the default and covers ~all of it.
  • No global CSS should be required. The full-width breakout (§5b) is achievable with scoped CSS + custom properties; it does not need a global rule.
  • The JS module sets only custom properties (element.style.setProperty('--parallax-pos', …)), never concrete CSS declarations — so all visual rules stay in the scoped stylesheet and the TS owns values, not style. Mirrors how SpectrumVisualizer drives --bar-height.

5b. Full-width breakout (the critical flag)

When FullWidth is true, the window must span the viewport width regardless of parent padding/margins. The robust, well-known technique (no JS needed for the width itself):

.parallax-window.full-width {
    width: 100vw;
    position: relative;
    left: 50%;
    right: 50%;
    margin-left: -50vw;
    margin-right: -50vw;
}

This re-centres a 100vw element under whatever offset parent it sits in, cancelling ancestor padding. Two caveats to document:

  • Horizontal scrollbar interaction. 100vw includes the scrollbar gutter on some browsers, causing a tiny horizontal overflow. Mitigate with overflow-x: clip (or hidden) on a layout ancestor, or accept the hairline. Note it; don't solve it inside the component (it is a page-layout concern).
  • Nested transformed ancestors. If an ancestor has a CSS transform/filter/ perspective, position: fixed-style escapes break — but the 100vw + negative-margin technique is transform-safe, which is exactly why it is preferred over a fixed-position approach. Good.

When FullWidth is false: plain width: 100%, no breakout.

5c. Layered images & crossfade

Two stacked layers inside the clipped window, both using background-image (not <img>), so the parallax background-position math applies uniformly to both:

.parallax-window           // overflow:hidden; height: var(--window-height); the clip box
  .layer.layer-1           // background-image: Image1; opacity: 1
  .layer.layer-2           // background-image: Image2; opacity: 0   (only if Image2 set)
  • Both layers share background-position-y: var(--parallax-pos) so they pan together.
  • Crossfade is pure CSS (§7): .parallax-window:hover .layer-2 { opacity: 1 } with transition: opacity 400ms ease on .layer-2. Mouse-out reverses automatically — no JS.
  • When Image2 is null, .layer-2 is not rendered at all (no empty layer, no hover cost).
  • Rationale for background-image over a second <img>: a single position variable drives both layers; with <img> we would need object-position plumbing on each. Background layers keep the parallax seam single-sourced. The accessibility cost (background images are invisible to assistive tech) is handled in §9.

6. JS / TS interop seam — and the critical placement question

6a. The placement problem — RESOLVED

The component lives in DeepDrftShared.Client (RCL, consumed by both hosts). The brief placed the TS module at DeepDrftPublic/Interop/parallax/parallax.ts, compiled by Microsoft.TypeScript.MSBuild into DeepDrftPublic/wwwroot/js/that JS ships only from the public host, leaving the CMS host (DeepDrftManager) with a component and no script behind it (silent no-op in the CMS). That conflict is what this decision resolves.

Resolved (Daniel, 2026-06-11): option 1 with the TypeScript toolchain — TS all the way, no plain JS. Microsoft.TypeScript.MSBuild is added to DeepDrftShared.Client and the TS source is co-located with the component in that RCL. RCL static assets are served from _content/DeepDrftShared.Client/… in both hosts automatically, so this is the only path where "both hosts can consume it" is true by construction.

Concrete placement:

  • TS source: DeepDrftShared.Client/Interop/parallax/parallax.ts (mirrors how DeepDrftPublic has Interop/audio/).
  • Compiled output: DeepDrftShared.Client/wwwroot/js/parallax/parallax.js (the tsconfig.json outDir for the shared lib).
  • Loaded by the component via IJSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/DeepDrftShared.Client/js/parallax/parallax.js").
  • tsconfig.json added to DeepDrftShared.Client, mirroring the one in DeepDrftPublic ("module": "ES2022", "target": "ES2020"), and must not be copied to output.
  • DeepDrftShared.Client.csproj gains <PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3" /> (same version as DeepDrftPublic.csproj).

This keeps the project's "TS not raw JS" convention intact across the shared RCL rather than carving out a plain-JS exception. The two rejected options are recorded below for the trail:

  • Option 2 — compile in DeepDrftPublic, duplicate/link output into DeepDrftManager. Build-time coupling: the CMS would depend on an asset produced by a sibling host and could ship a stale or missing copy. Rejected.
  • Option 3 — JSImport/scoped per-host. Each host owns its own copy; doubles the source. Rejected.

6b. Interop contract

The component holds an ElementReference to the window box and an IJSObjectReference to the imported module. Lifecycle owned by Blazor; math + listeners owned by JS — exactly the project's existing seam.

What the JS module exposes (ES module exports, invoked via the imported reference):

// register(element, options) → handle id
// Attaches an IntersectionObserver to `element`. While the element is intersecting,
// a passive scroll listener updates `--parallax-pos` from the §3 math each frame
// (rAF-throttled). While not intersecting, the scroll listener is detached.
register(element: HTMLElement, options: {
    speed: number;            // ParallaxSpeed, clamped [0,1]
    invertDirection: boolean; // InvertDirection — selects the §3 formula branch
    onNaturalHeight?: boolean // if true, module reads the bg image naturalHeight once
                              // decoded and reports it back (for the §4 default)
}): string;                  // returns a handle id

// unregister(handleId): tears down observer + scroll listener. Called from DisposeAsync.
unregister(handleId: string): void;

// (optional, for §4 default) getNaturalHeight(handleId): number | null

What Blazor calls:

  • OnAfterRenderAsync(firstRender): import() the module (once), then module.invokeVoidAsync("register", _elementRef, { speed, invertDirection, onNaturalHeight }), store the returned handle.
  • If onNaturalHeight and the consumer left WindowHeight default: read the reported natural height (either returned via a DotNetObjectReference callback mirroring setOnProgressCallback, or polled once via getNaturalHeight) and set --window-height: {naturalHeight/2}px. Callback is cleaner; mirror the audio module's dotNetRef.invokeMethodAsync pattern.
  • DisposeAsync: module.invokeVoidAsync("unregister", _handle) then dispose the module reference. Must implement IAsyncDisposable — a dangling scroll listener is a real perf leak, and the audit already established IAsyncDisposable discipline on the player provider.

Performance discipline (non-negotiable in the contract):

  • Scroll listener is passive ({ passive: true }) and rAF-throttled (one --parallax-pos write per frame max, not per scroll event).
  • The listener is attached only while the IntersectionObserver reports intersecting. Off-screen instances cost nothing. This is the whole reason the observer is in the contract.
  • Multiple instances on one page each get their own handle; the module may share a single scroll listener that fans out to all active handles (implementation detail, not contract).

7. Hover crossfade

Pure CSS, no JS (§5c):

  • .layer-2 (only rendered when Image2 set): opacity: 0; transition: opacity 400ms ease.
  • .parallax-window:hover .layer-2 { opacity: 1; }.
  • On mouse-out the transition reverses for free.
  • Touch devices have no hover. On touch, :hover may stick or never fire. Acceptable degradation: the at-rest Image1 shows; Image2 simply never reveals. Do not add a tap-to-toggle in the first cut (scope creep; the intended use is a desktop grayscale→colour flourish). Note it as a known limitation (§10).
  • The intended grayscale/colour pairing is a content choice (consumer supplies a grayscale Image1 and a colour Image2); the component does not apply a CSS filter: grayscale(). Keeping it content-driven means the component stays agnostic and the pairing can be any two images, not only desaturate/saturate. (If Daniel would rather the component own the grayscale via filter on a single image, that is a different, simpler design — one image, filter: grayscale(1) at rest → grayscale(0) on hover, no Image2 at all. Flagged as an alternative in §11.)

8. Frontend data / lifecycle flow

ParallaxImage rendered (either host)
   │
   ├─ SSR/first paint: static box at WindowHeight (or 300px fallback), Image1 background,
   │                   --parallax-pos at its progress-0 value. No JS yet. No flash of
   │                   wrong height if WindowHeight was passed explicitly.
   │
   └─ OnAfterRenderAsync(firstRender) [interactive]:
         import _content/DeepDrftShared.Client/js/parallax/parallax.js
         module.register(elementRef, { speed, onNaturalHeight })
            │
            ├─ IntersectionObserver gates a passive, rAF-throttled scroll listener
            │     → writes --parallax-pos each frame while in view
            │
            └─ (if default height) reports naturalHeight → component sets --window-height
   │
   └─ DisposeAsync: module.unregister(handle); dispose module ref

The component renders meaningfully without JS (static framed image) — progressive enhancement. The parallax is the enhancement, not the baseline. This matters for SSR and for the brief instant before WASM/interactive boot.


9. Accessibility

  • prefers-reduced-motion. The parallax pan is decorative motion. When @media (prefers-reduced-motion: reduce) is set, the JS module must not drive --parallax-pos (hold it at the progress-0/static value), and the crossfade transition duration collapses to 0ms via a scoped media query. The observer/listener can simply not attach under reduced-motion. This is a hard accessibility requirement, not optional.
  • Alt text. Background images are invisible to assistive tech. Provide an accessible name on the window box: render a visually-hidden element (or role="img" + aria-label on the window) carrying Alt1. When Image2 is purely a decorative hover flourish, it needs no separate alt (the hover is not conveying distinct information). Expose Alt1/Alt2 parameters so the consumer decides; if both are null and the image is decorative, the window gets role="presentation" / aria-hidden so screen readers skip it cleanly rather than announcing an unnamed image.
  • Keyboard / focus. The component is non-interactive (no seek, no controls); it needs no tab stop. The hover crossfade is decorative, so its absence under keyboard nav is fine.
  • Contrast. If consumers overlay text on the window (a likely hero use), that is the consumer's contrast responsibility; the component does not own overlaid content in this cut (no ChildContent slot — see §10 future options).

10. Known edge cases & limitations

  • Mobile Safari. The reason we avoid background-attachment: fixed entirely — it is broken/disabled on iOS Safari. The scroll-driven background-position approach works there. iOS momentum scrolling fires scroll events at frame cadence; the rAF throttle keeps it smooth.
  • Image preload / first-paint timing. The background image may not be decoded at first paint; the window shows its background colour until decode. For the natural-height default (§4) this also means the height recompute waits on decode → possible layout shift. Mitigate by encouraging explicit WindowHeight for above-the-fold hero usage; document it.
  • Image shorter than window. No pan range; renders static. Not an error (§3).
  • Full-width horizontal overflow. 100vw + scrollbar gutter (§5b). A page-layout concern, not solved inside the component.
  • Touch / no-hover. Image2 never reveals on touch (§7). Accepted limitation.
  • Many instances on one page. Each registers an observer; the shared scroll listener fans out. Verify with a stress page (e.g. 10 windows) before shipping; the in-view gating should keep cost flat.
  • SSR. No DOM at prerender; the component renders its static fallback and enhances after interactive boot. No getBoundingClientRect at SSR (would throw); all JS is behind OnAfterRenderAsync(firstRender).

11. Alternatives / open decisions

  1. JS module placement (§6a) — RESOLVED (Daniel, 2026-06-11). TS all the way: add Microsoft.TypeScript.MSBuild to DeepDrftShared.Client, author the source at Interop/parallax/parallax.ts, compile to wwwroot/js/parallax/parallax.js, load via dynamic import("./_content/DeepDrftShared.Client/js/parallax/parallax.js"). The public-host-only placement (option 2) and per-host duplication (option 3) were rejected — only RCL co-location lets both hosts consume it. See §6a for the full resolution.
  2. Parallax direction (§3) — RESOLVED (Daniel, 2026-06-11). Direction is now the InvertDirection component parameter (§2) rather than a hardcoded formula; the consumer chooses. Default (false) is the corrected top-on-entry form; true is the brief's literal form. Passed to the JS module via the register options object (§6b).
  3. Crossfade model (§7). Image2 second-image crossfade (as briefed) vs. a simpler single-image filter: grayscale() hover. Briefed design is more flexible (any two images); the filter design is less to wire and needs no second URL. Recommend the briefed two-image design; note the filter alternative exists if the only use is desaturate→colour.
  4. ChildContent overlay slot — deferred. A hero window often wants a headline laid over it. Not in this cut. Easy to add later as an optional RenderFragment ChildContent absolutely-positioned over the layers. Left out to keep the first cut tight; called out so the markup leaves room (the window box can host an overlay child without restructure).

12. File inventory

New:

  • DeepDrftShared.Client/Components/ParallaxImage.razor (+ .razor.cs, .razor.css).
  • DeepDrftShared.Client/Interop/parallax/parallax.ts (§6a) — the scroll/observer module, authored in TypeScript.
  • DeepDrftShared.Client/wwwroot/js/parallax/parallax.js — compiled output of the above (the tsconfig.json outDir); served from _content/DeepDrftShared.Client/… to both hosts.
  • DeepDrftShared.Client/tsconfig.json — mirrors DeepDrftPublic's ("module": "ES2022", "target": "ES2020"); not copied to output.

Changed:

  • DeepDrftShared.Client.csproj — add <PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3" /> (same version as DeepDrftPublic.csproj) plus the TypeScript MSBuild property/None-update wiring that keeps tsconfig.json out of output (mirror DeepDrftPublic.csproj). wwwroot/ is packed as static web assets by default for an RCL.
  • Consuming pages in either host that want the effect (no change required to adopt — it is additive).

Untouched (important): the entire audio TS bundle, the player, the proxy controllers, the data layer. This component is self-contained and shares nothing with the playback path.


13. What this plan deliberately does NOT do

  • Does not use background-attachment: fixed (broken on mobile Safari).
  • Does not add a tab stop / keyboard interaction (decorative, non-interactive).
  • Does not apply grayscale via CSS filter in the first cut — the grayscale/colour pairing is content-driven via Image1/Image2 (the filter alternative is recorded in §11.3).
  • Does not add a ChildContent overlay slot in the first cut (§11.4).
  • Does not drive --parallax-pos under prefers-reduced-motion (§9).
  • Does not solve full-width horizontal overflow inside the component (a page-layout concern).