All patterns

Infinite Scroll Footer

A footer spinner fills a slot that was already reserved, and the next page fades in above it.

loadingsubtlecalmautomatic · finite · intermediate · ~1.4s
Variant

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.

362 lines · react + motion only
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.

Paginated feedFile browserSearch resultsComment thread