# Paged List

A list that pages through items with numbered pagination on desktop and a Load more button on mobile.

Paged List is an open-source pattern from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/paged-list

**React**

**Example**

```tsx
import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListDemo() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
    />
  )
}
```

`PagedList` slices an array of items into pages and renders each one through `renderItem` inside a plain list. On desktop it shows a suiss/ui `Pagination` with previous and next links, the first and last page, the pages next to the current one, and ellipses for the gaps. The pagination is hidden when everything fits on one page.

On mobile (below `48rem`, via `useIsMobile`), the list grows instead of paging: a full-width secondary `Button` appends the next page of items, and focus moves to the first newly loaded item. Use it for long feeds, search results or topic lists where the user moves through a known set of items.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/paged-list
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns paged-list
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Pagination](https://ui.suiss.app/docs/components/pagination), `use-mobile`.

3. Copy the source into `components/patterns/paged-list.tsx`:

```tsx title="components/patterns/paged-list.tsx"
"use client"

import * as React from "react"
import { Button } from "@/components/ui/button"
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

function pageRange(page: number, pageCount: number) {
  const pages: (number | "ellipsis")[] = []
  for (let current = 1; current <= pageCount; current++) {
    const edge = current === 1 || current === pageCount
    const near = Math.abs(current - page) <= 1
    if (edge || near) {
      pages.push(current)
    } else if (pages.at(-1) !== "ellipsis") {
      pages.push("ellipsis")
    }
  }
  return pages
}

function PagedList<T>({
  items,
  pageSize = 10,
  page: pageProp,
  defaultPage = 1,
  onPageChange,
  getPageHref,
  getKey = (_, index) => index,
  renderItem,
  loadMoreLabel = "Load more",
  previousLabel,
  nextLabel,
  className,
  listClassName,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  items: T[]
  pageSize?: number
  page?: number
  defaultPage?: number
  onPageChange?: (page: number) => void
  getPageHref?: (page: number) => string
  getKey?: (item: T, index: number) => React.Key
  renderItem: (item: T, index: number) => React.ReactNode
  loadMoreLabel?: string
  previousLabel?: string
  nextLabel?: string
  listClassName?: string
}) {
  const isMobile = useIsMobile()
  const pageCount = Math.max(1, Math.ceil(items.length / pageSize))
  const [uncontrolledPage, setUncontrolledPage] = React.useState(defaultPage)
  const page = Math.min(Math.max(pageProp ?? uncontrolledPage, 1), pageCount)
  const [loaded, setLoaded] = React.useState(1)
  const listRef = React.useRef<HTMLUListElement>(null)
  const focusIndexRef = React.useRef<number | null>(null)

  const setPage = (next: number) => {
    if (next < 1 || next > pageCount || next === page) return
    if (pageProp === undefined) setUncontrolledPage(next)
    onPageChange?.(next)
    listRef.current?.scrollIntoView({ block: "nearest" })
  }

  const loadMore = () => {
    focusIndexRef.current = loaded * pageSize
    setLoaded((current) => Math.min(current + 1, pageCount))
  }

  React.useEffect(() => {
    const index = focusIndexRef.current
    if (index === null) return
    focusIndexRef.current = null
    listRef.current?.children[index]
      ?.querySelector<HTMLElement>("a, button, [tabindex]")
      ?.focus()
  })

  const start = isMobile ? 0 : (page - 1) * pageSize
  const end = isMobile ? loaded * pageSize : page * pageSize
  const visible = items.slice(start, end)

  const link = (target: number) => ({
    href: getPageHref?.(target) ?? "#",
    onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
      if (!getPageHref || target < 1 || target > pageCount)
        event.preventDefault()
      setPage(target)
    },
  })

  return (
    <div
      data-slot="paged-list"
      className={cn("flex flex-col gap-4", className)}
      {...props}
    >
      <ul
        ref={listRef}
        data-slot="paged-list-items"
        className={cn("flex flex-col", listClassName)}
      >
        {visible.map((item, offset) => (
          <li key={getKey(item, start + offset)} data-slot="paged-list-item">
            {renderItem(item, start + offset)}
          </li>
        ))}
      </ul>
      {isMobile
        ? end < items.length && (
            <Button
              data-slot="paged-list-load-more"
              variant="secondary"
              size="lg"
              block
              onClick={loadMore}
            >
              {loadMoreLabel}
            </Button>
          )
        : pageCount > 1 && (
            <Pagination data-slot="paged-list-pagination">
              <PaginationContent>
                <PaginationItem>
                  <PaginationPrevious
                    text={previousLabel}
                    aria-disabled={page === 1 || undefined}
                    {...link(page - 1)}
                  />
                </PaginationItem>
                {pageRange(page, pageCount).map((entry, index, pages) =>
                  entry === "ellipsis" ? (
                    <PaginationItem key={`after-${pages[index - 1]}`}>
                      <PaginationEllipsis />
                    </PaginationItem>
                  ) : (
                    <PaginationItem key={entry}>
                      <PaginationLink
                        isActive={entry === page}
                        {...link(entry)}
                      >
                        {entry}
                      </PaginationLink>
                    </PaginationItem>
                  )
                )}
                <PaginationItem>
                  <PaginationNext
                    text={nextLabel}
                    aria-disabled={page === pageCount || undefined}
                    {...link(page + 1)}
                  />
                </PaginationItem>
              </PaginationContent>
            </Pagination>
          )}
    </div>
  )
}

export { PagedList }
```

4. Update the import paths to match your project.

## Usage

```tsx
import { PagedList } from "@/components/patterns/paged-list"
```

```tsx
<PagedList<Topic> items={topics} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} className="mx-auto w-full max-w-2xl" />
```

- The current page is uncontrolled by default and starts at `defaultPage`. Pass `page` and `onPageChange` to control it.
- Out-of-range page values are clamped between `1` and the page count.
- Without `getPageHref`, page links use `#` and prevent navigation. With it, each link gets a real `href` and still updates the page on click.
- Provide `getKey` when items have stable IDs; the default key is the item's index.
- The mobile Load more state is internal and independent of `page`.

## Examples

### Start on later page

Opens on page 4 by setting `defaultPage`.

**Example**

```tsx
import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListStartOnLaterPage() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
      defaultPage={4}
    />
  )
}
```

### Single page

Renders every item without pagination when the items fit on one page.

**Example**

```tsx
import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListSinglePage() {
  return (
    <PagedList<Topic>
      items={topics.slice(0, 6)}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
    />
  )
}
```

### Page links

Gives each page link a real `href` through `getPageHref`.

**Example**

```tsx
import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListPageLinks() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
      getPageHref={(page: number) => `#page-${page}`}
    />
  )
}
```

### Custom labels

Replaces the previous, next and Load more labels.

**Example**

```tsx
import { ChatCircleIcon } from "@phosphor-icons/react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export default function PagedListCustomLabels() {
  return (
    <PagedList<Topic>
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
      loadMoreLabel="Show more topics"
      previousLabel="Newer"
      nextLabel="Older"
    />
  )
}
```

### Controlled

Owns the current page in state with `page` and `onPageChange`.

**Example**

```tsx
import { ChatCircleIcon } from "@phosphor-icons/react"
import * as React from "react"
import { PagedList } from "@/components/patterns/paged-list"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item render={<a href={`#topic-${topic.id}`} />}>
      <ItemMedia variant="icon">
        <ChatCircleIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

function ControlledExample(
  props: React.ComponentProps<typeof PagedList<Topic>>
) {
  const [page, setPage] = React.useState(2)

  return (
    <div className="flex flex-col gap-2">
      <PagedList {...props} page={page} onPageChange={setPage} />
      <p className="text-center text-sm text-muted-foreground">Page {page}</p>
    </div>
  )
}

export default function PagedListControlled() {
  return (
    <ControlledExample
      items={topics}
      pageSize={10}
      getKey={(topic: Topic) => topic.id}
      renderItem={(topic: Topic) => <TopicRow topic={topic} />}
      className="mx-auto w-full max-w-2xl"
    />
  )
}
```

## API Reference

### PagedList

The root that renders the visible items and the pagination or Load more control.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `T[]` | Required | The full array of items to page through. |
| `pageSize` | `number` | `10` | The number of items per page, and per Load more step on mobile. |
| `page` | `number` | - | The current page when controlled. |
| `defaultPage` | `number` | `1` | The page shown first when uncontrolled. |
| `onPageChange` | `(page: number) => void` | - | Called with the new page number when the user changes pages. |
| `getPageHref` | `(page: number) => string` | - | Returns the `href` for a page link, turning pagination into real links. |
| `getKey` | `(item: T, index: number) => React.Key` | `(_, index) => index` | Returns the React key for an item. |
| `renderItem` | `(item: T, index: number) => React.ReactNode` | Required | Renders one item; receives the item and its index in `items`. |
| `loadMoreLabel` | `string` | `"Load more"` | The label of the mobile Load more button. |
| `previousLabel` | `string` | - | Visible text for the previous link; it also becomes its accessible name. |
| `nextLabel` | `string` | - | Visible text for the next link; it also becomes its accessible name. |
| `listClassName` | `string` | - | Classes applied to the inner list element. |

Also accepts the props of `React.ComponentProps<"div">`.

## Accessibility

- Items render in a `ul`, each inside its own `li`.
- The desktop pagination is a navigation landmark; the current page link has `aria-current="page"`.
- Previous and next links get `aria-disabled` on the first and last page. Their default accessible names are Go to previous page and Go to next page.
- Changing pages scrolls the list into view. Loading more on mobile moves focus to the first focusable element of the first new item.

## Notes

Styling hooks: `data-slot="paged-list"`, `paged-list-items`, `paged-list-item`, `paged-list-pagination` and `paged-list-load-more`. Because `renderItem` output is wrapped in an `li`, return row content such as an `Item`, not another list item.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/patterns-paged-list--default.light.webp)

```tsx
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListExample() {
  return (
    <PagedList<Topic> items={topics} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListExample: View {
  var body: some View {
    PagedListDemo(topics: pagedListTopics)
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListExample() {
    PagedListDemo(pagedListTopics)
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}
```

`PagedList` takes the full `items` array and renders only the first `pageSize` items. When the user scrolls near the end, or presses the Load more button in the footer, the next page is appended.

The button disappears once every item is shown. Use it for long local lists where rendering everything at once is not needed.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/paged-list
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add paged-list
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add paged-list --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add paged-list --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/patterns/paged-list.tsx`:

```tsx title="uniwind/components/patterns/paged-list.tsx"
import { cn } from "cn"
import * as React from "react"
import { FlatList, type FlatListProps, View } from "react-native"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function PagedList<T>({
  items,
  pageSize = 10,
  getKey = (_, index) => index,
  renderItem,
  loadMoreLabel = "Load more",
  className,
  listClassName,
  ...props
}: Omit<
  FlatListProps<T>,
  | "data"
  | "renderItem"
  | "keyExtractor"
  | "onEndReached"
  | "ListFooterComponent"
  | "contentContainerClassName"
> & {
  items: T[]
  pageSize?: number
  getKey?: (item: T, index: number) => React.Key
  renderItem: (item: T, index: number) => React.ReactNode
  loadMoreLabel?: string
  className?: string
  listClassName?: string
}) {
  const pageCount = Math.max(1, Math.ceil(items.length / pageSize))
  const [loaded, setLoaded] = React.useState(1)
  const end = loaded * pageSize
  const hasMore = end < items.length
  const visible = React.useMemo(() => items.slice(0, end), [items, end])

  const loadMore = React.useCallback(() => {
    setLoaded((current) => Math.min(current + 1, pageCount))
  }, [pageCount])

  return (
    <FlatList
      data={visible}
      keyExtractor={(item, index) => String(getKey(item, index))}
      renderItem={({ item, index }) => <View>{renderItem(item, index)}</View>}
      onEndReached={hasMore ? loadMore : undefined}
      onEndReachedThreshold={0.5}
      className={cn("flex-1", className)}
      contentContainerClassName={listClassName}
      ListFooterComponent={
        hasMore ? (
          <Button
            variant="secondary"
            size="lg"
            block
            className="mt-4"
            onPress={loadMore}
          >
            <Text>{loadMoreLabel}</Text>
          </Button>
        ) : undefined
      }
      {...props}
    />
  )
}

export { PagedList }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/patterns/paged-list.tsx`:

```tsx title="nativewind/components/patterns/paged-list.tsx"
import { cn } from "cn"
import * as React from "react"
import { FlatList, type FlatListProps, View } from "react-native"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function PagedList<T>({
  items,
  pageSize = 10,
  getKey = (_, index) => index,
  renderItem,
  loadMoreLabel = "Load more",
  className,
  listClassName,
  ...props
}: Omit<
  FlatListProps<T>,
  | "data"
  | "renderItem"
  | "keyExtractor"
  | "onEndReached"
  | "ListFooterComponent"
  | "contentContainerClassName"
> & {
  items: T[]
  pageSize?: number
  getKey?: (item: T, index: number) => React.Key
  renderItem: (item: T, index: number) => React.ReactNode
  loadMoreLabel?: string
  className?: string
  listClassName?: string
}) {
  const pageCount = Math.max(1, Math.ceil(items.length / pageSize))
  const [loaded, setLoaded] = React.useState(1)
  const end = loaded * pageSize
  const hasMore = end < items.length
  const visible = React.useMemo(() => items.slice(0, end), [items, end])

  const loadMore = React.useCallback(() => {
    setLoaded((current) => Math.min(current + 1, pageCount))
  }, [pageCount])

  return (
    <FlatList
      data={visible}
      keyExtractor={(item, index) => String(getKey(item, index))}
      renderItem={({ item, index }) => <View>{renderItem(item, index)}</View>}
      onEndReached={hasMore ? loadMore : undefined}
      onEndReachedThreshold={0.5}
      className={cn("flex-1", className)}
      contentContainerClassName={listClassName}
      ListFooterComponent={
        hasMore ? (
          <Button
            variant="secondary"
            size="lg"
            block
            className="mt-4"
            onPress={loadMore}
          >
            <Text>{loadMoreLabel}</Text>
          </Button>
        ) : undefined
      }
      {...props}
    />
  )
}

export { PagedList }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Text](https://ui.suiss.app/docs/components/text).

2. Copy the source into `components/patterns/paged-list.tsx`:

```tsx title="stylesheet/components/patterns/paged-list.tsx"
import * as React from "react"
import {
  FlatList,
  type FlatListProps,
  type StyleProp,
  StyleSheet,
  View,
  type ViewStyle,
} from "react-native"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

const styles = StyleSheet.create({
  root: { flex: 1 },
  loadMore: { marginTop: 16 },
})

function PagedList<T>({
  items,
  pageSize = 10,
  getKey = (_, index) => index,
  renderItem,
  loadMoreLabel = "Load more",
  style,
  listStyle,
  ...props
}: Omit<
  FlatListProps<T>,
  | "data"
  | "renderItem"
  | "keyExtractor"
  | "onEndReached"
  | "ListFooterComponent"
  | "contentContainerStyle"
> & {
  items: T[]
  pageSize?: number
  getKey?: (item: T, index: number) => React.Key
  renderItem: (item: T, index: number) => React.ReactNode
  loadMoreLabel?: string
  listStyle?: StyleProp<ViewStyle>
}) {
  const pageCount = Math.max(1, Math.ceil(items.length / pageSize))
  const [loaded, setLoaded] = React.useState(1)
  const end = loaded * pageSize
  const hasMore = end < items.length
  const visible = React.useMemo(() => items.slice(0, end), [items, end])

  const loadMore = React.useCallback(() => {
    setLoaded((current) => Math.min(current + 1, pageCount))
  }, [pageCount])

  return (
    <FlatList
      data={visible}
      keyExtractor={(item, index) => String(getKey(item, index))}
      renderItem={({ item, index }) => <View>{renderItem(item, index)}</View>}
      onEndReached={hasMore ? loadMore : undefined}
      onEndReachedThreshold={0.5}
      style={[styles.root, style]}
      contentContainerStyle={listStyle}
      ListFooterComponent={
        hasMore ? (
          <Button
            variant="secondary"
            size="lg"
            block
            style={styles.loadMore}
            onPress={loadMore}
          >
            <Text>{loadMoreLabel}</Text>
          </Button>
        ) : undefined
      }
      {...props}
    />
  )
}

export { PagedList }
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

3. Copy the source and every file it uses:

```swift title="swift/Patterns/PagedList.swift"
import SwiftUI

enum PagedListMath {
  static func pageCount(total: Int, pageSize: Int) -> Int {
    max(1, Int((Double(total) / Double(max(pageSize, 1))).rounded(.up)))
  }

  static func visibleCount(loaded: Int, pageSize: Int, total: Int) -> Int {
    min(loaded * max(pageSize, 1), total)
  }

  static func shouldLoad(appearing index: Int, visible: Int, total: Int) -> Bool {
    index == visible - 1 && visible < total
  }
}

public struct PagedList<Item, ID: Hashable, Row: View>: View {
  let items: [Item]
  let id: KeyPath<Item, ID>
  let pageSize: Int
  let loadMoreLabel: LocalizedStringKey?
  let loadsOnEndReached: Bool
  let row: (Item, Int) -> Row

  @State private var loaded = 1

  public init(
    _ items: [Item],
    id: KeyPath<Item, ID>,
    pageSize: Int = 10,
    loadMoreLabel: LocalizedStringKey? = "Load more",
    loadsOnEndReached: Bool = true,
    @ViewBuilder row: @escaping (Item, Int) -> Row
  ) {
    self.items = items
    self.loadsOnEndReached = loadsOnEndReached
    self.id = id
    self.pageSize = pageSize
    self.loadMoreLabel = loadMoreLabel
    self.row = row
  }

  public var body: some View {
    let visible = PagedListMath.visibleCount(loaded: loaded, pageSize: pageSize, total: items.count)
    VStack(spacing: 16) {
      LazyVStack(alignment: .leading, spacing: 0) {
        ForEach(0..<visible, id: \.self) { index in
          row(items[index], index)
            .id(items[index][keyPath: id])
            .onAppear {
              if loadsOnEndReached, PagedListMath.shouldLoad(appearing: index, visible: visible, total: items.count) {
                loadMore()
              }
            }
        }
      }
      if visible < items.count, let loadMoreLabel {
        Button(action: loadMore) {
          Text(loadMoreLabel)
        }
        .buttonStyle(.variant(.secondary, size: .lg, block: true))
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }

  private func loadMore() {
    let pages = PagedListMath.pageCount(total: items.count, pageSize: pageSize)
    loaded = min(loaded + 1, pages)
  }
}

extension PagedList where Item: Identifiable, ID == Item.ID {
  public init(
    _ items: [Item],
    pageSize: Int = 10,
    loadMoreLabel: LocalizedStringKey? = "Load more",
    loadsOnEndReached: Bool = true,
    @ViewBuilder row: @escaping (Item, Int) -> Row
  ) {
    self.init(items, id: \.id, pageSize: pageSize, loadMoreLabel: loadMoreLabel, loadsOnEndReached: loadsOnEndReached, row: row)
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

4. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/patterns/PagedList.kt"
package com.esuiss.ui.patterns

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import kotlinx.coroutines.flow.distinctUntilChanged

@Composable
fun <T> PagedList(
    items: List<T>,
    modifier: Modifier = Modifier,
    pageSize: Int = 10,
    getKey: ((T, Int) -> Any)? = null,
    loadMoreLabel: String = "Load more",
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    renderItem: @Composable (T, Int) -> Unit,
) {
    val size = pageSize.coerceAtLeast(1)
    val pageCount = maxOf(1, (items.size + size - 1) / size)
    var loaded by rememberSaveable { mutableIntStateOf(1) }
    val end = minOf(loaded.coerceAtMost(pageCount) * size, items.size)
    val visible = items.subList(0, end)
    val hasMore = end < items.size
    val currentHasMore by rememberUpdatedState(hasMore)
    val currentEnd by rememberUpdatedState(end)
    val loadMore = { loaded = minOf(loaded + 1, pageCount) }
    LaunchedEffect(state) {
        snapshotFlow {
            val last = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
            last >= currentEnd - 1 && currentEnd > 0
        }
            .distinctUntilChanged()
            .collect { reached -> if (reached && currentHasMore) loadMore() }
    }
    LazyColumn(
        modifier = modifier,
        state = state,
        contentPadding = contentPadding,
    ) {
        itemsIndexed(visible, key = getKey?.let { keyOf -> { index: Int, item: T -> keyOf(item, index) } }) { index, item ->
            renderItem(item, index)
        }
        if (hasMore) {
            item(key = "paged-list-load-more") {
                Button(
                    text = loadMoreLabel,
                    onClick = loadMore,
                    modifier = Modifier.padding(top = 16.dp),
                    variant = ButtonVariant.Secondary,
                    size = ButtonSize.Lg,
                    block = true,
                )
            }
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { PagedList } from "@/components/patterns/paged-list"
```

```tsx
<PagedList<Topic> items={topics} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />
```

Pass `items`, `renderItem` and a `getKey` that returns a stable key for each item. `renderItem` receives the item and its index, not the `FlatList` info object.

Other `FlatList` props pass through, except `data`, `keyExtractor`, `onEndReached` and `ListFooterComponent`, which the list manages. Use `listClassName` to style the content container.

## Examples

### Small pages

Uses a `pageSize` of 3 so pages load sooner.

![Preview](https://ui.suiss.app/previews/patterns-paged-list--small-pages.light.webp)

```tsx
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListSmallPagesExample() {
  return (
    <PagedList<Topic> items={topics} pageSize={3} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListSmallPagesExample: View {
  var body: some View {
    PagedListDemo(topics: pagedListTopics, pageSize: 3)
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListSmallPagesExample() {
    PagedListDemo(pagedListTopics, pageSize = 3)
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}
```

### Single page

Fewer items than one page, so no Load more button appears.

![Preview](https://ui.suiss.app/previews/patterns-paged-list--single-page.light.webp)

```tsx
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListSinglePageExample() {
  return (
    <PagedList<Topic> items={topics.slice(0, 6)} pageSize={10} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListSinglePageExample: View {
  var body: some View {
    PagedListDemo(topics: Array(pagedListTopics.prefix(6)))
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListSinglePageExample() {
    PagedListDemo(pagedListTopics.take(6))
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}
```

### Custom labels

Renames the button with `loadMoreLabel`.

![Preview](https://ui.suiss.app/previews/patterns-paged-list--custom-labels.light.webp)

```tsx
import { ChatCircleIcon } from "phosphor-react-native"
import { PagedList } from "@/components/patterns/paged-list"
import { Icon } from "@/components/ui/icon"
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"

type Topic = { id: number; title: string; replies: number }

const subjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

const topics: Topic[] = Array.from({ length: 57 }, (_, index) => ({
  id: index + 1,
  title: `${subjects[index % subjects.length]} · thread ${index + 1}`,
  replies: (index * 7) % 23,
}))

function TopicRow({ topic }: { topic: Topic }) {
  return (
    <Item onPress={() => undefined}>
      <ItemMedia variant="icon">
        <Icon as={ChatCircleIcon} />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>{topic.title}</ItemTitle>
        <ItemDescription>{topic.replies} replies</ItemDescription>
      </ItemContent>
    </Item>
  )
}

export function PagedListCustomLabelsExample() {
  return (
    <PagedList<Topic> items={topics} pageSize={3} getKey={(topic: Topic) => topic.id} renderItem={(topic: Topic) => <TopicRow topic={topic} />} loadMoreLabel="Show more topics" />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PagedListCustomLabelsExample: View {
  var body: some View {
    PagedListDemo(topics: pagedListTopics, pageSize: 3, loadMoreLabel: "Show more topics")
  }
}

struct PagedListTopic: Identifiable {
  let id: Int
  let title: String
  let replies: Int
}

let pagedListSubjects = [
  "Getting started",
  "Dark mode tokens",
  "Form validation",
  "Release notes",
  "Accessibility",
  "Theming",
  "Server components",
  "Animations",
]

let pagedListTopics: [PagedListTopic] = (0..<57).map { index in
  PagedListTopic(
    id: index + 1,
    title: "\(pagedListSubjects[index % pagedListSubjects.count]) · thread \(index + 1)",
    replies: (index * 7) % 23
  )
}

struct PagedListDemo: View {
  let topics: [PagedListTopic]
  var pageSize = 10
  var loadMoreLabel: LocalizedStringKey = "Load more"

  var body: some View {
    PagedList(topics, pageSize: pageSize, loadMoreLabel: loadMoreLabel) { topic, _ in
      Item(action: {}) {
        ItemMedia(.icon) {
          Ph.chatCircle.regular
        }
        ItemContent {
          ItemTitle(verbatim: topic.title)
          ItemDescription(verbatim: "\(topic.replies) replies")
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemDescription
import com.esuiss.ui.components.ItemMedia
import com.esuiss.ui.components.ItemMediaVariant
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.patterns.PagedList

@Composable
fun PagedListCustomLabelsExample() {
    PagedListDemo(pagedListTopics, pageSize = 3, loadMoreLabel = "Show more topics")
}

class PagedListTopic(val id: Int, val title: String, val replies: Int)

val pagedListSubjects = listOf(
    "Getting started",
    "Dark mode tokens",
    "Form validation",
    "Release notes",
    "Accessibility",
    "Theming",
    "Server components",
    "Animations",
)

val pagedListTopics = List(57) { index ->
    PagedListTopic(
        id = index + 1,
        title = "${pagedListSubjects[index % pagedListSubjects.size]} · thread ${index + 1}",
        replies = (index * 7) % 23,
    )
}

@Composable
fun PagedListDemo(items: List<PagedListTopic>, pageSize: Int = 10, loadMoreLabel: String = "Load more") {
    PagedList(
        items = items,
        modifier = Modifier.fillMaxWidth().height(560.dp),
        pageSize = pageSize,
        getKey = { topic, _ -> topic.id },
        loadMoreLabel = loadMoreLabel,
    ) { topic, _ ->
        PagedListRow(topic)
    }
}

@Composable
fun PagedListRow(topic: PagedListTopic) {
    Item(onClick = {}) {
        ItemMedia(variant = ItemMediaVariant.Icon) {
            Icon(PhosphorIcons.Regular.ChatCircle, contentDescription = null)
        }
        ItemContent {
            ItemTitle(topic.title)
            ItemDescription("${topic.replies} replies")
        }
    }
}
```

## API Reference

### PagedList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `T[]` | Required | The full array of items. |
| `pageSize` | `number` | `10` | Items added per page. Defaults to `10`. |
| `getKey` | `(item: T, index: number) => React.Key` | `(_, index) => index` | Returns a unique key for an item. Defaults to the index. |
| `renderItem` | `(item: T, index: number) => React.ReactNode` | Required | Renders one item from the item and its index. |
| `loadMoreLabel` | `string` | `"Load more"` | Text of the footer button. Defaults to `Load more`. |
| `className` | `string` | - | Classes for the `FlatList`. |
| `listClassName` | `string` | - | Classes for the list's content container. |

Also accepts the props of `FlatListProps<T>`.

## Accessibility

- The Load more button is a `Button`, so screen reader users can load the next page without scrolling to the end.

## Notes

- The next page loads when the scroll position is within half a screen of the end (`onEndReachedThreshold` of `0.5`).
- The footer is a full-width `secondary` button.

