Summary
Step 2 of #5392. #5415 restored pull-to-refresh on mobile by bounding the hero's aurora glow below 1024px and scoping the overscroll suppression to desktop:
@media (min-width: 1024px) {
html { overscroll-behavior-y: none; }
}
That was always meant to be a stepping stone, not the destination. At ≥1024px three layers are still position: fixed — heroContent ((site)/page.tsx), HeroFloatingCards.stage, and HeroThemeReel.backdropGlow — so the suppression has to stay, and macOS trackpads still get no native rubber-band in Safari, Chrome or Firefox. That is the case #3032 was actually filed for.
This issue is to finish the job: contain the desktop-pinned layers so overscroll-behavior-y: none can be deleted outright rather than gated.
Why sticky is the answer
A fixed layer is glued to the viewport and is not part of the document. When the document rubber-bands past its own bottom edge, the fixed layer does not lift with it — so it is sitting in the exposed gap. That is the bleed.
A sticky layer is in the document. It lifts with it, and cannot paint below the document's own bottom edge. That is structural rather than empirical, which is why it lets the rule be removed instead of narrowed.
Concretely, at max scroll today the fixed glow occupies viewport y=48–1098 — the entire viewport — so any bottom overscroll necessarily paints it.
Prototype: the effect survives
DOM surgery on a dev build, canary banner hidden to match production, one position: absolute; inset: 0 rail per layer as a direct child of heroScope, with backdropGlow + stage + heroContent all position: sticky; top: var(--appshell-header-height):
| scroll |
0 |
100 |
200 |
400 |
760 |
1400 |
2200 |
max |
pixels differing from fixed |
0 |
0 |
0 |
0 |
0 |
0 |
0 |
0 |
Document height unchanged (3540px). Verified at 1280×900, 1280×600, 1440×1400 and 1024×768. Layer geometry matches fixed everywhere except max scroll, where sticky releases — invisible, because the opaque showcaseOverlay already covers the hero there. Hero CTA hit-testing unaffected (elementFromPoint returns the button before and after).
So the tuned pin-and-cover effect is reproducible with sticky. The work is in the plumbing, not the design.
Two traps, both paid for already
1. The rails must go in the right place. The pinned layers are not children of heroScope. Its only two children are swipeArea (HeroReelProvider's root, 760px, holding every hero layer) and showcaseOverlay. A sticky layer inside swipeArea releases after ~48px, so each layer needs a containing block spanning hero + showcase.
Insert each rail immediately before showcaseOverlay, not at the front of heroScope. Inserting at the front puts the layers behind themeFill (opaque, still inside swipeArea, later in tree order) and produces a 29%-of-pixels difference at scroll 0 that looks exactly like sticky failing but is pure paint order.
2. display: contents on swipeArea is NOT a shortcut — I tried it. <Theme> already renders display: contents, so swipeArea is the only real box between the layers and heroScope, which makes "just make swipeArea contents and flip the three layers to sticky" look like a two-line fix. Measured, it is not:
- document height 3540 → 5302px (+1762):
heroContent becomes in-flow and adds its own height on top of heroSpacer, which still reserves 760px
heroContent lands at viewport y=1858 at scroll 0 — below the fold, after the spacer
- glow
x shifts 40 → −520: left: 50% + translateX(-50%) no longer resolves the way it did
Anyone taking this will reach for that shortcut. It needs the rails, plus heroSpacer bookkeeping and a centering change.
Scope
- Three
absolute; inset: 0 rails as direct children of heroScope, inserted before showcaseOverlay; the pinned layers move into them. This means the layers currently rendered by HeroReelCards inside HeroReelProvider have to be reachable as direct children of heroScope while keeping reel context and <Theme>.
heroContent, stage, backdropGlow: fixed → sticky at ≥1024px.
- Centering:
left: 50% + transform: translateX(-50%) → margin-inline: auto inside a full-width rail (verified equivalent: x=40, w=1200 either way).
heroSpacer height bookkeeping once the hero is sticky rather than fixed.
- Delete
@media (min-width: 1024px) { html { overscroll-behavior-y: none } } from apps/docsite/src/app/globals.css.
Add a comment where it will save someone
fixed breaks if an ancestor gains transform or filter. sticky breaks additionally if any ancestor gains overflow: hidden or overflow: auto — silently, no error, the hero just stops pinning and nobody notices until they look at the home page.
.astryx-layout-content (AppShell) already carries overflow: clip. That is safe — clip creates no scroll container — but it is one property value away from un-pinning the entire landing page. Worth a comment on that rule. (It is also why the box-shadow slab workaround in #5392 fails.)
What is not yet proven
The prototype covered static scroll positions with transitions and animations disabled, in Chromium only. Untested: the theme-swap animation mid-scroll, resize while scrolled, prefers-reduced-motion, and Safari — which is the browser the rubber-band actually matters in.
Layout equivalence is proven; the feel of the tuned effect is not. That is the careful visual pass #3032 asked for, and it is the real cost here.
Worth sizing honestly
Desktop has no pull-to-refresh. The entire payoff is native macOS trackpad rubber-band — the mobile fix restored a function, this restores a feel. Windows and Linux have no vertical bounce, so the rule is already a no-op there. It is a refactor of a hand-tuned effect for a polish win on the site's front door: worth doing deliberately, not worth rushing.
Summary
Step 2 of #5392. #5415 restored pull-to-refresh on mobile by bounding the hero's aurora glow below 1024px and scoping the overscroll suppression to desktop:
That was always meant to be a stepping stone, not the destination. At ≥1024px three layers are still
position: fixed—heroContent((site)/page.tsx),HeroFloatingCards.stage, andHeroThemeReel.backdropGlow— so the suppression has to stay, and macOS trackpads still get no native rubber-band in Safari, Chrome or Firefox. That is the case #3032 was actually filed for.This issue is to finish the job: contain the desktop-pinned layers so
overscroll-behavior-y: nonecan be deleted outright rather than gated.Why
stickyis the answerA
fixedlayer is glued to the viewport and is not part of the document. When the document rubber-bands past its own bottom edge, the fixed layer does not lift with it — so it is sitting in the exposed gap. That is the bleed.A
stickylayer is in the document. It lifts with it, and cannot paint below the document's own bottom edge. That is structural rather than empirical, which is why it lets the rule be removed instead of narrowed.Concretely, at max scroll today the fixed glow occupies viewport y=48–1098 — the entire viewport — so any bottom overscroll necessarily paints it.
Prototype: the effect survives
DOM surgery on a dev build, canary banner hidden to match production, one
position: absolute; inset: 0rail per layer as a direct child ofheroScope, withbackdropGlow+stage+heroContentallposition: sticky; top: var(--appshell-header-height):fixedDocument height unchanged (3540px). Verified at 1280×900, 1280×600, 1440×1400 and 1024×768. Layer geometry matches
fixedeverywhere except max scroll, where sticky releases — invisible, because the opaqueshowcaseOverlayalready covers the hero there. Hero CTA hit-testing unaffected (elementFromPointreturns the button before and after).So the tuned pin-and-cover effect is reproducible with
sticky. The work is in the plumbing, not the design.Two traps, both paid for already
1. The rails must go in the right place. The pinned layers are not children of
heroScope. Its only two children areswipeArea(HeroReelProvider's root, 760px, holding every hero layer) andshowcaseOverlay. A sticky layer insideswipeAreareleases after ~48px, so each layer needs a containing block spanning hero + showcase.Insert each rail immediately before
showcaseOverlay, not at the front ofheroScope. Inserting at the front puts the layers behindthemeFill(opaque, still insideswipeArea, later in tree order) and produces a 29%-of-pixels difference at scroll 0 that looks exactly like sticky failing but is pure paint order.2.
display: contentsonswipeAreais NOT a shortcut — I tried it.<Theme>already rendersdisplay: contents, soswipeAreais the only real box between the layers andheroScope, which makes "just makeswipeAreacontents and flip the three layers to sticky" look like a two-line fix. Measured, it is not:heroContentbecomes in-flow and adds its own height on top ofheroSpacer, which still reserves 760pxheroContentlands at viewport y=1858 at scroll 0 — below the fold, after the spacerxshifts 40 → −520:left: 50%+translateX(-50%)no longer resolves the way it didAnyone taking this will reach for that shortcut. It needs the rails, plus
heroSpacerbookkeeping and a centering change.Scope
absolute; inset: 0rails as direct children ofheroScope, inserted beforeshowcaseOverlay; the pinned layers move into them. This means the layers currently rendered byHeroReelCardsinsideHeroReelProviderhave to be reachable as direct children ofheroScopewhile keeping reel context and<Theme>.heroContent,stage,backdropGlow:fixed→stickyat ≥1024px.left: 50%+transform: translateX(-50%)→margin-inline: autoinside a full-width rail (verified equivalent: x=40, w=1200 either way).heroSpacerheight bookkeeping once the hero is sticky rather than fixed.@media (min-width: 1024px) { html { overscroll-behavior-y: none } }fromapps/docsite/src/app/globals.css.Add a comment where it will save someone
fixedbreaks if an ancestor gainstransformorfilter.stickybreaks additionally if any ancestor gainsoverflow: hiddenoroverflow: auto— silently, no error, the hero just stops pinning and nobody notices until they look at the home page..astryx-layout-content(AppShell) already carriesoverflow: clip. That is safe —clipcreates no scroll container — but it is one property value away from un-pinning the entire landing page. Worth a comment on that rule. (It is also why thebox-shadowslab workaround in #5392 fails.)What is not yet proven
The prototype covered static scroll positions with transitions and animations disabled, in Chromium only. Untested: the theme-swap animation mid-scroll, resize while scrolled,
prefers-reduced-motion, and Safari — which is the browser the rubber-band actually matters in.Layout equivalence is proven; the feel of the tuned effect is not. That is the careful visual pass #3032 asked for, and it is the real cost here.
Worth sizing honestly
Desktop has no pull-to-refresh. The entire payoff is native macOS trackpad rubber-band — the mobile fix restored a function, this restores a feel. Windows and Linux have no vertical bounce, so the rule is already a no-op there. It is a refactor of a hand-tuned effect for a polish win on the site's front door: worth doing deliberately, not worth rushing.