## Load More — Async Sentinel that loads before you hit the end. Docs: https://www.interior.dev/docs/load-more Reference: https://www.interior.dev/reference/load-more License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/load-more.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRef, useState } from "react"; import { LoadMore } from "@/components/interior/load-more"; type Order = { id: string; customer: string; total: string }; export function OrderFeed() { const scroller = useRef(null); const [orders, setOrders] = useState([]); const [cursor, setCursor] = useState(null); async function loadPage() { const res = await fetch(`/api/orders?limit=20&cursor=${cursor ?? ""}`); if (!res.ok) throw new Error("Order feed unavailable"); const page: { orders: Order[]; next: string | null } = await res.json(); setOrders((prev) => [...prev, ...page.orders]); setCursor(page.next); return page.next !== null; } return (
{orders.map((order) => ( ))}
report(error)} />
); } ``` ### Props - `onLoad` (`() => unknown`) Fetches the next page. Resolving `false` means that was the last one and the sentinel stops observing; rejecting puts the footer into its retry state. - `hasMore` (`boolean`) — default: `true` Cursor-driven alternative to resolving `false`. Either one ends the feed; setting it back to true reopens the sentinel. - `auto` (`boolean`) — default: `true` False observes nothing and leaves a plain button. The button exists in both modes. - `rootRef` (`React.RefObject`) — default: `undefined` The scroll container. Required when the list scrolls inside an element rather than the document, because rootMargin is measured against the root and nothing else. - `rootMargin` (`string`) — default: `"600px 0px"` How far ahead of the footer the page is requested. This is the whole component: 600px of runway is roughly one flick of the thumb. - `maxAutoLoads` (`number`) — default: `3` Consecutive automatic loads allowed while the sentinel stays on screen. The count resets the moment the sentinel scrolls out of view. - `steps` (`number`) — default: `8` Cells in the progress meter drawn along the bottom lip of the button. - `expected` (`number`) — default: `900` Milliseconds the meter is paced against. It is an estimate, not a promise: the meter parks on its last cell until the request actually settles. - `labels` (`Partial>`) — default: `undefined` Overrides for the idle, loading, error and end copy. The button sizes to the longest one, so all four are measured up front. - `onError` (`(error: unknown) => void`) — default: `undefined` Receives the rejection so it can reach your logger. The component keeps the message to itself. - `className` (`string`) — default: `""` Appended last to the footer wrapper. ### Behavior notes - The sentinel fires once per page: a request already in flight, an exhausted feed, or a second intersection callback in the same frame is dropped by a ref, so one scroll never buys two copies of page four. - A page shorter than the viewport leaves the sentinel on screen, and the naive version answers by draining the entire dataset in three frames; consecutive automatic loads are capped at maxAutoLoads and the counter resets only when the sentinel actually leaves the viewport. - A rejected request never auto-retries. Automatic loading is blocked until a person presses the button, because a sentinel sitting on a failing endpoint is a denial-of-service attack on your own API. - Scroll position is not an input method, so the footer is a real button in every state and reachable by keyboard and assistive tech even when auto is on; it is marked aria-disabled rather than disabled, so a focused button is never yanked out of the tab order mid-page. - The four states share one grid cell, so the footer is the width of its longest label from first paint and the list below it never jumps when loading turns into caught up. The live region speaks once, at the end and on failure, not on every frame of the meter. - Responses that land after unmount, or after a newer request was issued, write nothing. Under prefers-reduced-motion the meter is not paced at all and the labels swap instantly, which is the only part that was ever decorative. ### Source (`components/interior/load-more.tsx`) ```tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode, RefObject } from "react"; import { motion, useReducedMotion } from "motion/react"; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const INSTANT = { duration: 0 } as const; const SPIN = { duration: 0.7, ease: "linear", repeat: Infinity } as const; export type LoadMoreStatus = "idle" | "loading" | "error" | "end"; export type UseLoadMoreOptions = { onLoad: () => unknown; hasMore?: boolean; auto?: boolean; rootRef?: RefObject; rootMargin?: string; maxAutoLoads?: number; onError?: (error: unknown) => void; }; export type UseLoadMoreReturn = { status: LoadMoreStatus; paused: boolean; sentinelRef: RefObject; load: () => void; }; export function useLoadMore({ onLoad, hasMore = true, auto = true, rootRef, rootMargin = "600px 0px", maxAutoLoads = 3, onError, }: UseLoadMoreOptions): UseLoadMoreReturn { const [phase, setPhase] = useState<"idle" | "loading" | "error">("idle"); const [ended, setEnded] = useState(false); const [paused, setPaused] = useState(false); const sentinelRef = useRef(null); const observer = useRef(null); const seq = useRef(0); const busy = useRef(false); const alive = useRef(true); const runs = useRef(0); const done = useRef(false); const blocked = useRef(false); const fetchMore = useRef(onLoad); fetchMore.current = onLoad; const fail = useRef(onError); fail.current = onError; const more = useRef(hasMore); more.current = hasMore; const reobserve = useCallback(() => { const io = observer.current; const el = sentinelRef.current; if (io && el) { io.unobserve(el); io.observe(el); } }, []); const run = useCallback( (manual: boolean) => { if (busy.current || done.current || !more.current) return; if (manual) { runs.current = 0; blocked.current = false; setPaused(false); } else { if (blocked.current) return; if (runs.current >= maxAutoLoads) { setPaused(true); return; } runs.current += 1; } busy.current = true; const id = ++seq.current; setPhase("loading"); Promise.resolve() .then(() => fetchMore.current()) .then( (result) => { busy.current = false; if (!alive.current || id !== seq.current) return; setPhase("idle"); if (result === false) { done.current = true; setEnded(true); return; } reobserve(); }, (error: unknown) => { busy.current = false; if (!alive.current || id !== seq.current) return; blocked.current = true; fail.current?.(error); setPhase("error"); }, ); }, [maxAutoLoads, reobserve], ); useEffect(() => { if (hasMore) { done.current = false; setEnded(false); } }, [hasMore]); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); useEffect(() => { if (!auto || ended) return; const el = sentinelRef.current; if (!el || typeof IntersectionObserver === "undefined") return; const io = new IntersectionObserver( (entries) => { const entry = entries[entries.length - 1]; if (!entry) return; if (entry.isIntersecting) { run(false); return; } runs.current = 0; setPaused(false); }, { root: rootRef?.current ?? null, rootMargin, threshold: 0 }, ); observer.current = io; io.observe(el); return () => { io.disconnect(); observer.current = null; }; }, [auto, ended, rootMargin, rootRef, run]); const load = useCallback(() => run(true), [run]); const status: LoadMoreStatus = ended || !hasMore ? "end" : phase; return { status, paused, sentinelRef, load }; } function ChevronMark() { return ( ); } function CheckMark() { return ( ); } function AlertMark() { return ( ); } function SpinnerMark({ spinning }: { spinning: boolean }) { return ( ); } export type LoadMoreLabels = Record; const DEFAULT_LABELS: LoadMoreLabels = { idle: "Load more", loading: "Loading", error: "Couldn’t load. Try again", end: "You’re all caught up", }; const ORDER: LoadMoreStatus[] = ["idle", "loading", "error", "end"]; const TONE: Record = { idle: "text-stone-700 dark:text-stone-200", loading: "text-stone-500 dark:text-stone-400", error: "text-red-600 dark:text-red-400", end: "text-stone-500 dark:text-stone-400", }; export type LoadMoreProps = { onLoad: () => unknown; hasMore?: boolean; auto?: boolean; rootRef?: RefObject; rootMargin?: string; maxAutoLoads?: number; labels?: Partial; onError?: (error: unknown) => void; className?: string; }; export function LoadMore({ onLoad, hasMore = true, auto = true, rootRef, rootMargin = "600px 0px", maxAutoLoads = 3, labels, onError, className = "", }: LoadMoreProps) { const reduced = useReducedMotion(); const { status, sentinelRef, load } = useLoadMore({ onLoad, hasMore, auto, rootRef, rootMargin, maxAutoLoads, onError, }); const fade = reduced ? INSTANT : CROSSFADE; const text: LoadMoreLabels = { ...DEFAULT_LABELS, ...labels }; const icons: Record = { idle: , loading: , error: , end: , }; const inert = status === "loading" || status === "end"; return (
{status === "error" || status === "end" ? text[status] : ""}
); } ```