Infinite Scroll Footer
A footer spinner fills a slot that was already reserved, and the next page fades in above it.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Infinite Scroll Footer
*
* The footer is the whole contract: a sentinel at the end of the list
* asks for the next page, a spinner sits in a slot that is already
* reserved, and the new rows fade in above it. Because the slot never
* changes height, arriving rows push nothing around — the reading
* position stays exactly where the reader left it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; pass `onLoadMore` + `loading` to drive it from
* your own pagination.
* Requires the automatic JSX runtime (default since React 17).
*/
export type InfiniteScrollFooterProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your request state. Left undefined, the component
* paginates its own sample data so the file runs as-is. */
loading?: boolean;
/** Whether another page exists. Only consulted while controlled. */
hasMore?: boolean;
/** Fires when the sentinel comes into view and a page is wanted. */
onLoadMore?: () => void;
/** Rows per page for the embedded sample data. */
pageSize?: number;
/** Stand-in request time used only while uncontrolled, in ms. */
loadMs?: number;
/** Panel width — px number or any CSS length. */
width?: number | string;
/** Visible height of the scroll area, in px. */
viewportHeight?: number;
};
type VariantConfig = {
/** Entry travel for an arriving row, in px. */
lift: number;
/** Seconds between consecutive rows of the same page. */
stagger: number;
fadeSeconds: number;
/** Seconds per turn of the footer arc. */
spinSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: rows are mostly type, so they travel on an over-damped
// spring and never scale. Variants change how far a row travels and how
// far apart the arrivals are — never how much they wobble.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a straight fade. For long reference lists where pages arrive
// constantly and the motion must not become the experience.
subtle: {
lift: 4,
stagger: 0.03,
fadeSeconds: 0.22,
spinSeconds: 1,
spring: { type: "spring", stiffness: 620, damping: 52 },
},
// The all-purpose setting: enough travel to notice the arrival.
default: {
lift: 9,
stagger: 0.05,
fadeSeconds: 0.28,
spinSeconds: 0.85,
spring: { type: "spring", stiffness: 480, damping: 42 },
},
// Longer travel and a wider gap between rows, for short pages where
// each arrival is worth watching.
playful: {
lift: 14,
stagger: 0.07,
fadeSeconds: 0.32,
spinSeconds: 0.7,
spring: { type: "spring", stiffness: 380, damping: 33 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent yields surfaces that are correctly toned on a light page
* and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_ROWS = [
{ title: "Renewal terms — Northwind", meta: "Contract · 2.1 MB" },
{ title: "Q3 board summary", meta: "Document · 640 KB" },
{ title: "Support volume by region", meta: "Report · 1.4 MB" },
{ title: "Payment retries — July", meta: "Export · 320 KB" },
{ title: "Seat allocation policy", meta: "Document · 88 KB" },
{ title: "Refund exceptions log", meta: "Export · 1.1 MB" },
{ title: "Onboarding checklist v4", meta: "Document · 210 KB" },
{ title: "Churn interviews — batch 6", meta: "Notes · 540 KB" },
{ title: "Invoice reconciliation", meta: "Report · 2.8 MB" },
];
export default function InfiniteScrollFooter({
variant = "default",
loading,
hasMore,
onLoadMore,
pageSize = 3,
loadMs = 1100,
width = 336,
viewportHeight = 244,
}: InfiniteScrollFooterProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const scrollRef = useRef<HTMLDivElement | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [count, setCount] = useState(pageSize);
const [selfLoading, setSelfLoading] = useState(false);
const controlled = loading !== undefined;
const isLoading = controlled ? loading : selfLoading;
const moreAvailable = controlled ? hasMore !== false : count < SAMPLE_ROWS.length;
const requestPage = useCallback(() => {
if (onLoadMore) onLoadMore();
if (controlled) return;
setSelfLoading(true);
}, [controlled, onLoadMore]);
// The sentinel is the trigger, not a scroll listener: the browser tells
// us when the end of the list is on screen, which is both cheaper and
// correct when the container resizes.
useEffect(() => {
const sentinel = sentinelRef.current;
const root = scrollRef.current;
if (!sentinel || !root || isLoading || !moreAvailable) return;
if (typeof IntersectionObserver === "undefined") {
// Very old browsers and some test environments have no observer.
// Ask on the next tick rather than synchronously inside the
// effect, which would cascade a render.
const timer = setTimeout(requestPage, 0);
return () => clearTimeout(timer);
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) requestPage();
},
{ root, rootMargin: "48px" }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [isLoading, moreAvailable, requestPage]);
// Uncontrolled fallback so the file runs on its own; the moment a
// caller passes `loading`, this timer stays out of the way.
useEffect(() => {
if (controlled || !selfLoading) return;
const timer = setTimeout(() => {
setCount((current) => Math.min(current + pageSize, SAMPLE_ROWS.length));
setSelfLoading(false);
}, loadMs);
return () => clearTimeout(timer);
}, [controlled, selfLoading, pageSize, loadMs]);
const rows = SAMPLE_ROWS.slice(0, count);
return (
<div
style={{
width,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(4),
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
padding: "13px 16px 11px",
borderBottom: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.4 }}>
FILES
</span>
<span style={{ fontSize: 11, opacity: 0.5 }}>{rows.length} loaded</span>
</div>
<div
ref={scrollRef}
aria-busy={isLoading}
style={{
height: viewportHeight,
overflowY: "auto",
padding: "6px 0 0",
// A stable scrollbar gutter keeps the rows from shifting
// sideways the moment the list becomes long enough to scroll.
scrollbarGutter: "stable",
}}
>
{rows.map((row, index) => (
<motion.div
key={row.title}
// Rows already on screen keep their mounted state, so only
// the page that just arrived plays an entrance.
initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.lift }}
animate={{ opacity: 1, y: 0 }}
transition={{
opacity: {
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: (index % pageSize) * cfg.stagger,
},
y: { ...cfg.spring, delay: (index % pageSize) * cfg.stagger },
}}
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "9px 16px",
}}
>
<span
style={{
width: 26,
height: 26,
borderRadius: 7,
flexShrink: 0,
background: tone(9),
display: "grid",
placeItems: "center",
}}
>
<svg width={13} height={13} viewBox="0 0 16 16" fill="none">
<path
d="M4 2h5l3 3v9H4z"
stroke="currentColor"
strokeOpacity="0.55"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<path
d="M9 2v3h3"
stroke="currentColor"
strokeOpacity="0.55"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 13,
fontWeight: 500,
lineHeight: 1.3,
}}
>
{row.title}
</span>
<span
style={{
display: "block",
fontSize: 11.5,
opacity: 0.52,
marginTop: 2,
}}
>
{row.meta}
</span>
</span>
</motion.div>
))}
{/* The reserved slot. It exists at the same height whether the
next page is loading, finished or not coming — so the list
above it never moves when the state changes. */}
<div
ref={sentinelRef}
style={{
height: 46,
display: "grid",
placeItems: "center",
position: "relative",
}}
>
<AnimatePresence initial={false} mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 12,
opacity: 0.6,
}}
>
<motion.svg
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
animate={reduceMotion ? { rotate: 0 } : { rotate: 360 }}
transition={
reduceMotion
? { duration: 0 }
: {
duration: cfg.spinSeconds,
repeat: Infinity,
ease: "linear",
}
}
>
<circle
cx="12"
cy="12"
r="9.5"
stroke="currentColor"
strokeOpacity="0.22"
strokeWidth="2.4"
/>
{/* A third of the 59.7px circumference: enough arc to
read a direction of travel. */}
<circle
cx="12"
cy="12"
r="9.5"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeDasharray="17 42.7"
transform="rotate(-90 12 12)"
/>
</motion.svg>
<span style={{ fontWeight: 500 }}>Loading more</span>
</motion.div>
) : moreAvailable ? null : (
<motion.span
key="end"
initial={{ opacity: 0 }}
animate={{ opacity: 0.42 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ fontSize: 12 }}
>
End of results
</motion.span>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}About this pattern
Pagination that never interrupts reading. A sentinel at the end of the list asks for the next page as it comes into view, the footer slot fills with a turning arc, and the arriving rows fade and lift into place a few tens of milliseconds apart so the eye can see where the list grew. The slot is a fixed height whether it holds a spinner, an end-of-results note or nothing at all — that constant is the whole trick, because a footer that grows and collapses drags the rows above it up and down and costs the reader their place. The spinner also resolves the ambiguity a bare list can't: the difference between more results coming and no more results existing.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Photo gallery
Reaching the end of the grid quietly requests the following page and the new items settle in above the footer.
Related patterns
- Background Refresh HintA two-pixel tinted band travels the panel's top edge while data refetches, without interrupting reading.
- Content Placeholder PulsePlaceholder blocks rise and fall together on one slow cadence, so the region reads as dormant rather than busy.
- Lazy Section RevealA below-the-fold section fades and lifts the first time it comes into view, and never again after that.
