import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArrowUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Pressable } from "./Pressable"; import { SURFACE } from "./surfaces"; // A floating "back to top" button that fades in once the current page's scroll container is // scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a //
— the normal pages share App's, the channel page renders its own // — so this follows whichever one is mounted. Portaled to so its fixed position is // viewport-relative regardless of transformed ancestors. export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) { const { t } = useTranslation(); const [show, setShow] = useState(false); const scrollerRef = useRef(null); // Listen on the document in the CAPTURE phase and read the scroller off the event, rather than // looking
up when this binds. Scroll events don't bubble, but they do capture — and the // lookup was a race the lazy pages lost: on the FIRST visit to the channel page its chunk is // still loading, so the Suspense fallback is on screen, there is no
to find, and the // effect gave up for good (its dep never changes again). The button then stayed dead for that // whole visit and came back only once the chunk was cached. Nothing to find, nothing to race. useEffect(() => { const onScroll = (e: Event) => { const el = e.target as HTMLElement | null; if (!el || el.tagName !== "MAIN") return; scrollerRef.current = el; setShow(el.scrollTop > threshold); }; document.addEventListener("scroll", onScroll, { passive: true, capture: true }); return () => document.removeEventListener("scroll", onScroll, { capture: true }); }, [threshold]); // On navigation, re-read where the new page sits: usually the top (so hide), but some pages // restore their scroll position. Missing
here is now harmless — a page that hasn't // rendered hasn't scrolled either, and the listener above picks it up the moment it does. useEffect(() => { const el = document.querySelector("main"); scrollerRef.current = el; // eslint-disable-next-line react-hooks/set-state-in-effect -- reading the DOM is the point —
is swapped by the page switch above us, and only after that commit can its scrollTop be read at all. Nothing derivable in render knows where the new page is scrolled to. setShow(!!el && el.scrollTop > threshold); }, [dep, threshold]); // A keyboard press hides the button it was on: the focus goes to the page it scrolled (its
// is a focus rest, not a Tab stop), not left on an invisible control (E28 review F7). const toTop = (e: React.MouseEvent) => { const main = scrollerRef.current ?? document.querySelector("main"); main?.scrollTo({ top: 0, behavior: "smooth" }); if (e.detail === 0) main?.focus({ preventScroll: true }); }; // No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields) // commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"]. // The accessible name comes from a visually-hidden child instead, which those selectors can't match. // Shape (docs/DESIGN.md A2): round because it floats over the content, 3rem (48) on mouse and touch — // a lone corner button at a toolbar rung gets lost. return createPortal( , document.body, ); }