Files
siftlode/frontend/src/lib/useScrollFade.ts
T
peter a032324513 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).
2026-07-17 03:14:37 +02:00

60 lines
2.9 KiB
TypeScript

import { useEffect, useState, type CSSProperties } from "react";
// Scroll-affordance for a scroll container (typically one whose native scrollbar is hidden with
// `.no-scrollbar`, but the page scroller keeps its bar and takes the fade anyway):
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
export function useScrollFade(fadePx = 20): {
ref: (node: HTMLElement | null) => void;
style: CSSProperties;
} {
const [el, setEl] = useState<HTMLElement | null>(null);
const [fade, setFade] = useState({ up: false, down: false });
useEffect(() => {
if (!el) return;
// Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's
// hook sits above the whole page tree, so a new object here would re-render on every frame
// of every scroll. The two booleans only flip at the ends of the range.
const update = () => {
const up = el.scrollTop > 1;
const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
setFade((prev) => (prev.up === up && prev.down === down ? prev : { up, down }));
};
update();
el.addEventListener("scroll", update, { passive: true });
// Watch the scroller AND its content. Content that grows or shrinks — a lazy page loading in,
// a thread's messages decrypting, a list being filtered — changes neither the scroller's own
// box nor its scrollTop, so without the children nothing would re-evaluate the edges.
const ro = new ResizeObserver(update);
const observeAll = () => {
ro.disconnect();
ro.observe(el);
for (const child of Array.from(el.children)) ro.observe(child);
};
observeAll();
// Children get SWAPPED, and a ResizeObserver holding a detached node simply never fires
// again. Measured: the Plex page opened with no bottom fade over a 3119px list, because the
// node observed at mount was the Suspense fallback the real page then replaced. (Same
// lazy-page race that broke BackToTop.) Re-observe whenever the child list changes.
const mo = new MutationObserver(() => {
observeAll();
update();
});
mo.observe(el, { childList: true });
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [el]);
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${
fade.down ? fadePx : 0
}px), transparent)`;
return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } };
}