improvement(scroll): fade the page scroller's clipped edges

The subdued edge-fade the rail and side panels use now hints at more content on
the page scroller too, which the feed, the channel page and Plex all scroll
inside. Both <main>s move into one PageScroller so the invariants they share —
the load-bearing scrollbar gutter, and the bar staying visible because hiding it
would zero that gutter — are stated once.

Two fixes the hook needed first: it set fresh state on every scroll event, which
above a page tree would re-render it per frame; and it watched a single child
captured at mount, so a lazy page swapping out its Suspense fallback left the
observer on a detached node and the fade frozen at its pre-load value (measured
on the Plex page: no bottom fade over a 3119px list).
This commit is contained in:
2026-07-17 03:14:37 +02:00
parent 0b56eb9d53
commit a032324513
4 changed files with 72 additions and 24 deletions
+35
View File
@@ -0,0 +1,35 @@
import { type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";
// The page's scroll container. Two of these exist and are never both mounted: App renders one for
// the normal pages, ChannelPage renders its own (it's a separate branch of App's tree, not a page
// inside the usual one). The feed, the channel page and Plex browse all scroll inside it rather
// than owning a scroller — which is why the edge-fade lives here once instead of three times.
//
// Two invariants this element carries, both learned the hard way:
//
// - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count
// from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content
// lands within a scrollbar's width of the viewport height the two feed each other: scrollbar
// appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider ->
// taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the
// loop. On the channel page it also stops the header/logo/buttons shifting sideways when you
// switch from the tall Videos tab to the short About tab.
// - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero
// the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
// fade next to its scrollbar, not instead of it.
//
// The fade state lives in here rather than in App so that a flip re-renders this component alone —
// `children` arrives as an already-built element, so React skips the page subtree underneath.
export default function PageScroller({ children }: { children: ReactNode }) {
const fade = useScrollFade();
return (
<main
ref={fade.ref}
style={fade.style}
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
>
{children}
</main>
);
}