# Load More

A button that adds the next page to a list, in place, without the page jumping.

Source: [load-more.tsx](https://github.com/jakejarvis/jarvis-ui/blob/main/src/components/ui/load-more.tsx)

```tsx
"use client";

import * as React from "react";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";
import {
  LoadMore,
  LoadMoreButton,
  LoadMoreContent,
  LoadMoreCount,
} from "@/components/ui/load-more";
import { people } from "@/examples/data";

const actions = [
  "rotated the API key prod-ingest",
  "updated the billing email",
  "invited sam@example.com",
  "turned on SSO",
  "changed the plan to Team, billed yearly",
  "removed a member from Platform",
];

const events = Array.from({ length: 18 }, (_, index) => ({
  id: index,
  person: people[index % people.length],
  action: actions[index % actions.length],
  time: `${(index + 1) * 7}m ago`,
}));

const pageSize = 5;

export default function LoadMoreAuditLog() {
  const [shown, setShown] = React.useState(pageSize);

  const loadMore = async () => {
    await new Promise((resolve) => setTimeout(resolve, 700));
    setShown((count) => Math.min(count + pageSize, events.length));
  };

  return (
    <LoadMore
      onLoadMore={loadMore}
      hasMore={shown < events.length}
      count={shown}
      total={events.length}
      className="w-full max-w-md"
    >
      <LoadMoreContent render={<ItemGroup />} aria-label="Audit log">
        {events.slice(0, shown).map((event) => (
          <Item key={event.id} size="sm">
            <ItemMedia>
              <Avatar className="size-7">
                <AvatarImage src={event.person.image} alt="" />
                <AvatarFallback>{event.person.initials}</AvatarFallback>
              </Avatar>
            </ItemMedia>
            <ItemContent>
              <ItemTitle>{event.person.name}</ItemTitle>
              <ItemDescription>{event.action}</ItemDescription>
            </ItemContent>
            <span className="text-2xs text-faint">{event.time}</span>
          </Item>
        ))}
      </LoadMoreContent>
      <LoadMoreButton />
      <LoadMoreCount />
    </LoadMore>
  );
}
```

## Installation

Add the registry, once per project:

```bash
npx shadcn@latest registry add @jarvis=https://ui.jarv.is/r/{name}.json
```

Then add the component:

```bash
npx shadcn@latest add @jarvis/load-more
```

## Usage

```tsx
"use client";

import * as React from "react";

import {
  LoadMore,
  LoadMoreButton,
  LoadMoreContent,
  LoadMoreCount,
} from "@/components/ui/load-more";

type Entry = { id: string; text: string };

export function Activity({ firstPage, total }: { firstPage: Entry[]; total: number }) {
  const [events, setEvents] = React.useState(firstPage);

  const loadMore = async () => {
    const response = await fetch(`/api/events?after=${events.at(-1)?.id ?? ""}`);
    const next: Entry[] = await response.json();
    setEvents((loaded) => [...loaded, ...next]);
  };

  return (
    <LoadMore
      onLoadMore={loadMore}
      hasMore={events.length < total}
      count={events.length}
      total={total}
    >
      <LoadMoreContent render={<ul />}>
        {events.map((event) => (
          <li key={event.id}>{event.text}</li>
        ))}
      </LoadMoreContent>
      <LoadMoreButton />
      <LoadMoreCount />
    </LoadMore>
  );
}
```

`onLoadMore` fetches the next page and adds it to your state. Return a promise, so the button
knows when it's done. `LoadMoreContent` holds the rows in whatever markup suits them, such as an
[Item](https://ui.jarv.is/components/item.md) group, a list or a grid of cards, and its direct children are the rows.
Give it `render` to make it a `ul` or a `tbody`. `count` is how many rows are shown, so the count
is right before the page loads its scripts. Without it, the rows are counted in the browser.

When a page lands, its rows fade in, and focus moves to the first of them, so someone using a
keyboard or screen reader carries on from what they asked for. A screen reader also hears how many
arrived. The button and count opt out of scroll anchoring, so new rows push them down rather than
moving what you're reading. The spinner shows only once a page has taken `pendingDelay`
milliseconds, and stays at least `minPending`, so it never flickers. A second press while a page
loads does nothing, and once `hasMore` is `false`, the button gives way to a marker at the end.

## Composition

- `LoadMore`
  - `LoadMoreContent`
  - `LoadMoreButton`
  - `LoadMoreCount`

## Examples

### Errors

If `onLoadMore` rejects, the rows loaded so far stay, and the button offers to try again, with a
message under it. Here the first try fails.

```tsx
"use client";

import * as React from "react";

import {
  LoadMore,
  LoadMoreButton,
  LoadMoreContent,
  LoadMoreCount,
} from "@/components/ui/load-more";

const builds = Array.from({ length: 12 }, (_, index) => ({
  id: 4120 - index,
  branch: ["main", "billing-flag", "sso-azure", "export-s3"][index % 4],
}));

export default function LoadMoreRetry() {
  const [shown, setShown] = React.useState(4);
  const attempts = React.useRef(0);

  const loadMore = async () => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    attempts.current += 1;
    if (attempts.current === 1) throw new Error("Network error");
    setShown((count) => Math.min(count + 4, builds.length));
  };

  return (
    <LoadMore
      onLoadMore={loadMore}
      hasMore={shown < builds.length}
      count={shown}
      total={builds.length}
      className="w-full max-w-sm"
    >
      <LoadMoreContent render={<ul />} className="divide-y-[0.5px] divide-border-muted">
        {builds.slice(0, shown).map((build) => (
          <li key={build.id} className="flex items-center justify-between py-2">
            <span className="font-mono">#{build.id}</span>
            <span className="text-muted-foreground">{build.branch}</span>
          </li>
        ))}
      </LoadMoreContent>
      <LoadMoreButton />
      <LoadMoreCount />
    </LoadMore>
  );
}
```

### Loading on scroll

`auto` loads the next page on its own as the button comes within 200 pixels of view, in the window
or a scrolling container, so the list keeps going as you read. Focus stays where it is. After a
failure, or a page that adds no rows, it waits for a press rather than asking again.

```tsx
"use client";

import * as React from "react";

import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { LoadMore, LoadMoreButton, LoadMoreContent } from "@/components/ui/load-more";
import { ScrollArea } from "@/components/ui/scroll-area";

const projects = Array.from({ length: 24 }, (_, index) => ({
  id: index,
  name: `project-${String(index + 1).padStart(2, "0")}`,
  updated: `Updated ${index + 1}d ago`,
}));

export default function LoadMoreAuto() {
  const [shown, setShown] = React.useState(6);

  const loadMore = async () => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    setShown((count) => Math.min(count + 6, projects.length));
  };

  return (
    <ScrollArea className="h-80 w-full rounded-lg shadow-[inset_0_0_0_0.5px_var(--border-muted)]">
      <LoadMore onLoadMore={loadMore} hasMore={shown < projects.length} className="p-4">
        <LoadMoreContent className="grid grid-cols-2 gap-3">
          {projects.slice(0, shown).map((project) => (
            <Card key={project.id} size="sm">
              <CardHeader>
                <CardTitle className="font-mono">{project.name}</CardTitle>
                <CardDescription>{project.updated}</CardDescription>
              </CardHeader>
            </Card>
          ))}
        </LoadMoreContent>
        <LoadMoreButton auto />
      </LoadMore>
    </ScrollArea>
  );
}
```

## Props

| Part | Extends |
| --- | --- |
| `LoadMore` | `<div>` |
| `LoadMoreContent` | `<div>` |
| `LoadMoreButton` | [`Button`](https://ui.jarv.is/components/button.md) |
| `LoadMoreCount` | `<p>` |

The tables leave out `className`, `style` and `render`. Parts built on Base UI take them as its [styling](https://base-ui.com/react/handbook/styling) and [composition](https://base-ui.com/react/handbook/composition) guides describe.

### LoadMore

| Prop            | Type            | Description                                                                                                                                                                                                      |
| --------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onLoadMore`    | `() => unknown` | Fetches and adds the next page. Return a promise: if it rejects, the button offers to try again.                                                                                                                 |
| `hasMore`       | `boolean`       | Whether there's another page. Once it's `false`, the button gives way to a marker at the end.                                                                                                                    |
| `count?`        | `number`        | How many rows are shown, for `LoadMoreCount` and what a screen reader hears. Pass it so the server's render has the count. Without it, the direct children of `LoadMoreContent` are counted once the page loads. |
| `total?`        | `number`        | How many rows there are in all, when it's known, for `LoadMoreCount`.                                                                                                                                            |
| `pendingDelay?` | `number`        | How long a page can take, in milliseconds, before the spinner shows, so a quick one never flashes it. Default: `150`                                                                                             |
| `minPending?`   | `number`        | How long the spinner stays once it shows, in milliseconds, so it never flickers. Default: `400`                                                                                                                  |

### LoadMoreButton

| Prop                     | Type                                                                                 | Description                                                                                                                                                                                       |
| ------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `focusableWhenDisabled?` | `boolean`                                                                            | Whether the button should be focusable when disabled. Default: `false`                                                                                                                            |
| `nativeButton?`          | `boolean`                                                                            | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). Default: `true` |
| `variant?`               | `"destructive" \| "ghost" \| "link" \| "outline" \| "primary" \| "secondary"`        | Default: `"secondary"`                                                                                                                                                                            |
| `size?`                  | `"default" \| "icon" \| "icon-lg" \| "icon-sm" \| "icon-xs" \| "lg" \| "sm" \| "xs"` | Default: `"default"`                                                                                                                                                                              |
| `loadingLabel?`          | `React.ReactNode`                                                                    | The label while a page loads. Default: `"Loading…"`                                                                                                                                               |
| `retryLabel?`            | `React.ReactNode`                                                                    | The label after a page fails to load. Default: `"Try again"`                                                                                                                                      |
| `endLabel?`              | `React.ReactNode`                                                                    | The marker that takes the button's place once there's nothing more. Default: `"All caught up"`                                                                                                    |
| `errorMessage?`          | `React.ReactNode`                                                                    | Shown under the button when a page fails to load. Default: `"Couldn’t load more. Check your connection and try again."`                                                                           |
| `auto?`                  | `boolean`                                                                            | Loads the next page on its own as the button comes within 200 pixels of view. After a failure, or a page that adds no rows, it waits for a press. Default: `false`                                |

### LoadMoreCount

| Prop        | Type                                                   | Description                                                    |
| ----------- | ------------------------------------------------------ | -------------------------------------------------------------- |
| `children?` | `((count: number, total?: number) => React.ReactNode)` | Renders the count yourself, from the rows shown and the total. |

`LoadMoreButton` takes the props of [Button](https://ui.jarv.is/components/button.md) too, and its children are the
label at rest.
