ecomcn
All blocks

Browse/component

Load more

Progressive loading for a product list: a progress rule and “Showing 24 of 312” count, a Load more button, optional hybrid or infinite auto-loading, focus moved to the first new item, and an announced count. The pattern Baymard found performs best, in one component.

Step 1Register the namespace — once per project

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json

Step 2Add the block

$ npx shadcn@latest add @ecomcn/load-more
Preview — live
100%

Usage

With TanStack Query's useInfiniteQuery
import { LoadMore } from "@/components/ecomcn/load-more"
import { ProductGrid } from "@/components/ecomcn/product-grid"

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isError } =
  useInfiniteQuery({ queryKey: ["products", filters], queryFn, getNextPageParam, initialPageParam: 1 })
const products = data?.pages.flatMap((page) => page.items) ?? []

<ProductGrid id="results" products={products} loadingMore={isFetchingNextPage} />
<LoadMore
  controls="results"            // focus lands on the first new product
  shown={products.length}
  total={data?.pages[0].total}
  hasMore={hasNextPage}
  loading={isFetchingNextPage}
  error={isError ? "Couldn't load more products." : null}
  mode="hybrid"                 // first page on click, the rest on approach
  onLoadMore={() => fetchNextPage()}
/>
Keep the depth in the URL
// ?page=3 means pages 1–3 are on screen, so reload and Back restore them.
// Use replaceState: loading more is not something Back should undo.
// useFilterParams already drops "page" whenever a filter changes.
const page = Number(new URLSearchParams(location.search).get("page") ?? 1)

Props

Read straight out of the source at build time, so this cannot drift from the file you install.

LoadMoreProps
/** Items currently rendered. */
  shown: number
  /** Total results, if your backend knows it. Drives the count and the progress rule. */
  total?: number
  /** Defaults to `shown < total`. Pass it when `total` is unknown. */
  hasMore?: boolean
  loading?: boolean
  onLoadMore: () => void | Promise<unknown>
  /**
   * `button` — only on click (the default).
   * `hybrid` — the first page needs a click, later pages load as the shopper nears the end.
   * `infinite` — always loads on approach. Keeps the footer out of reach; use sparingly.
   */
  mode?: "button" | "hybrid" | "infinite"
  /** Shown with a retry button when the last load failed. */
  error?: string | null
  /**
   * The id of the list this appends to (e.g. your ProductGrid). After a
   * click, focus moves to the first new item, so a keyboard user carries on
   * from where the new results start instead of back at the button.
   */
  controls?: string
  label?: string
  noun?: { one: string; other: string }
  locale?: string
  /** How early infinite modes start loading. */
  rootMargin?: string

Source

components/ecomcn/load-more.tsx
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

export interface LoadMoreProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** Items currently rendered. */
  shown: number
  /** Total results, if your backend knows it. Drives the count and the progress rule. */
  total?: number
  /** Defaults to `shown < total`. Pass it when `total` is unknown. */
  hasMore?: boolean
  loading?: boolean
  onLoadMore: () => void | Promise<unknown>
  /**
   * `button` — only on click (the default).
   * `hybrid` — the first page needs a click, later pages load as the shopper nears the end.
   * `infinite` — always loads on approach. Keeps the footer out of reach; use sparingly.
   */
  mode?: "button" | "hybrid" | "infinite"
  /** Shown with a retry button when the last load failed. */
  error?: string | null
  /**
   * The id of the list this appends to (e.g. your ProductGrid). After a
   * click, focus moves to the first new item, so a keyboard user carries on
   * from where the new results start instead of back at the button.
   */
  controls?: string
  label?: string
  noun?: { one: string; other: string }
  locale?: string
  /** How early infinite modes start loading. */
  rootMargin?: string
}

const FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'

export function LoadMore({
  shown,
  total,
  hasMore: hasMoreProp,
  loading = false,
  onLoadMore,
  mode = "button",
  error,
  controls,
  label = "Load more",
  noun = { one: "product", other: "products" },
  locale,
  rootMargin = "600px 0px",
  className,
  ...props
}: LoadMoreProps) {
  const hasMore = hasMoreProp ?? (total !== undefined ? shown < total : true)
  const format = React.useMemo(() => new Intl.NumberFormat(locale), [locale])
  const plural = React.useMemo(() => new Intl.PluralRules(locale), [locale])
  const word = (n: number) => (plural.select(n) === "one" ? noun.one : noun.other)

  const status =
    total !== undefined
      ? `Showing ${format.format(shown)} of ${format.format(total)} ${word(total)}`
      : `Showing ${format.format(shown)} ${word(shown)}`

  // Silent until the shopper has asked for more, so the page doesn't speak
  // on load; afterwards the live region announces each new count once.
  const [requested, setRequested] = React.useState(false)
  // Hybrid mode arms itself on the first click — and disarms when the list
  // starts over (a filter or sort change), so every new result set asks once.
  const [armedAt, setArmedAt] = React.useState<number | null>(null)
  const armed = armedAt !== null && shown >= armedAt
  const auto = hasMore && !loading && !error && (mode === "infinite" || (mode === "hybrid" && armed))

  // One request per page: if a load returns nothing new, don't hammer it.
  const requestedAt = React.useRef<number | null>(null)
  const focusFrom = React.useRef<number | null>(null)
  const sentinel = React.useRef<HTMLDivElement>(null)

  const load = React.useCallback(
    (from: "click" | "auto") => {
      if (from === "auto" && requestedAt.current === shown) return
      requestedAt.current = shown
      setRequested(true)
      if (from === "click") {
        focusFrom.current = shown
        setArmedAt(shown)
      }
      void onLoadMore()
    },
    [onLoadMore, shown]
  )

  // Re-created whenever loading settles: a fresh observer reports the current
  // intersection immediately, so a sentinel that is *still* on screen after a
  // fast load triggers the next page instead of waiting for a scroll.
  React.useEffect(() => {
    const node = sentinel.current
    if (!auto || !node || typeof IntersectionObserver === "undefined") return
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries.some((entry) => entry.isIntersecting)) load("auto")
      },
      { rootMargin }
    )
    observer.observe(node)
    return () => observer.disconnect()
  }, [auto, load, rootMargin])

  // After a click-triggered load lands, move focus to the first new item.
  React.useEffect(() => {
    const from = focusFrom.current
    if (from === null || shown <= from || !controls) return
    focusFrom.current = null
    const item = document.getElementById(controls)?.children[from] as HTMLElement | undefined
    if (!item) return
    const target = item.matches(FOCUSABLE) ? item : item.querySelector<HTMLElement>(FOCUSABLE)
    if (target) {
      target.focus()
    } else {
      item.tabIndex = -1
      item.focus()
    }
  }, [shown, controls])

  const progress = total ? Math.min(1, shown / total) : undefined

  return (
    <div
      ref={sentinel}
      data-slot="load-more"
      className={cn("text-sm", className)}
      {...props}
    >
      {/* The rule is the progress bar: filled to the share already shown. */}
      <div aria-hidden className="h-px w-full bg-border">
        {progress !== undefined ? (
          <div
            className="h-px bg-foreground transition-[width] duration-500 motion-reduce:transition-none"
            style={{ width: `${progress * 100}%` }}
          />
        ) : null}
      </div>

      <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 pt-4">
        <p className="text-muted-foreground tabular-nums">{status}</p>

        {error ? null : hasMore ? (
          <Button
            type="button"
            variant="outline"
            aria-controls={controls}
            disabled={loading}
            onClick={() => load("click")}
            className="w-full sm:w-auto"
          >
            {loading ? "Loading…" : label}
          </Button>
        ) : (
          <p className="text-muted-foreground">
            {total !== undefined
              ? `That’s all ${format.format(total)} ${word(total)}.`
              : "That’s everything."}
          </p>
        )}
      </div>

      {error ? (
        <div role="alert" className="mt-3 flex flex-wrap items-center gap-3">
          <p>{error}</p>
          <Button type="button" variant="outline" size="sm" onClick={() => load("click")}>
            Try again
          </Button>
        </div>
      ) : null}

      <span className="sr-only" role="status" aria-live="polite">
        {requested && !loading ? status : ""}
      </span>
    </div>
  )
}