diff --git a/app/components/layout/Footer.module.css b/app/components/layout/Footer.module.css index 5fafbfbc1..b0e7cb34d 100644 --- a/app/components/layout/Footer.module.css +++ b/app/components/layout/Footer.module.css @@ -48,6 +48,15 @@ } } +.marqueePlaceholder { + min-height: calc( + 3 * + (var(--font-xs) * var(--line-height) + var(--s-1) * 2) + + var(--s-2) * + 2 + ); +} + .marqueeRow { display: flex; gap: var(--s-2); diff --git a/app/components/layout/Footer.tsx b/app/components/layout/Footer.tsx index afa44ea5a..62bf60211 100644 --- a/app/components/layout/Footer.tsx +++ b/app/components/layout/Footer.tsx @@ -1,5 +1,12 @@ +import clsx from "clsx"; import { Braces, CircleHelp, Hand, Heart, HeartHandshake } from "lucide-react"; -import type { ReactNode } from "react"; +import { + type ReactNode, + type RefObject, + useEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import * as R from "remeda"; @@ -55,6 +62,7 @@ import { PatreonIcon } from "../icons/Patreon"; import styles from "./Footer.module.css"; const MARQUEE_ROWS_COUNT = 3; +const MARQUEE_MOUNT_MARGIN = "400px"; const SITEMAP_COLUMNS = [ { @@ -275,22 +283,34 @@ export function Footer() { type Patron = NonNullable["patrons"]>[number]; function PatronMarquee() { + const ref = useRef(null); + const hasScrolledIntoView = useHasScrolledIntoView(ref); + + return ( +
+ {hasScrolledIntoView ? : null} +
+ ); +} + +function PatronMarqueeRows() { const { patrons } = usePatrons(); if (!patrons || patrons.length === 0) return null; const rows = R.chunk(patrons, Math.ceil(patrons.length / MARQUEE_ROWS_COUNT)); - return ( -
- {rows.map((row) => ( -
- - -
- ))} + return rows.map((row) => ( +
+ +
- ); + )); } function PatronChips({ @@ -357,6 +377,30 @@ function FooterCard({ ); } +function useHasScrolledIntoView(ref: RefObject) { + const [hasScrolledIntoView, setHasScrolledIntoView] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return; + + setHasScrolledIntoView(true); + observer.disconnect(); + }, + { rootMargin: MARQUEE_MOUNT_MARGIN }, + ); + observer.observe(element); + + return () => observer.disconnect(); + }, [ref]); + + return hasScrolledIntoView; +} + function customThemeChipStyle( customTheme: Patron["customTheme"], ): React.CSSProperties {