---
title: Pagination
description: Navigate a result set by page using state or destinations.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# Pagination

> Navigate a result set by page using state or destinations.

Use Pagination when a result set has a known page count. The application owns data fetching and the mapping from page numbers to URLs or controlled state.

## Basic usage

```tsx
import { Pagination } from 'moraine'

export function Example() {
  return <Pagination total={100} itemsPerPage={10} defaultPage={1} />
}
```

## Anatomy

```text
Pagination [component; slot=root; <nav>]
├── list [slot]
│   ├── listItem [slot]
│   │   └── prev [slot]
│   ├── listItem [slot]
│   │   └── item [slot]
│   └── listItem [slot]
│       └── next [slot]
└── status [internal]
```

## Usage

### Current page

Use `page` with `onPageChange` for controlled navigation. `total` is the number of records, not the page count; the component derives the page count from `itemsPerPage`. The surrounding application owns data fetching and URL updates.

```tsx
import { Pagination } from 'moraine'
import { createSignal } from 'solid-js'

export function PageState() {
  const [page, setPage] = createSignal(1)

  return (
    <div class="flex flex-col gap-3 items-center">
      <Pagination total={100} itemsPerPage={10} page={page()} onPageChange={setPage} />
      <p class="text-xs text-muted-foreground">
        Active page: <span class="text-foreground font-medium">{page()}</span> of 10
      </p>
    </div>
  )
}
```

### Rendering links

Use `to(page)` by itself to render enabled page controls as anchors. For router integration, use `itemAs` for numbered items and `controlAs` for Previous/Next; these props are independent and `to` still only generates the destination. This example uses Solid Router's `A` and `useSearchParams` to keep the current page synchronized with destinations such as `/pagination?page=2`. Boundary Previous/Next controls remain native disabled buttons even when `controlAs` is set.

```tsx
import { A, useSearchParams } from '@solidjs/router'
import { Pagination } from 'moraine'
import { createMemo } from 'solid-js'

export function LinkRendering() {
  const [params] = useSearchParams()
  const page = createMemo(() => {
    const requested = Number(params.page)
    return Number.isFinite(requested) ? Math.min(5, Math.max(1, Math.trunc(requested))) : 1
  })
  return (
    <div class="flex w-full justify-center">
      <Pagination
        itemAs={A}
        controlAs={A}
        total={50}
        itemsPerPage={10}
        page={page()}
        siblingCount={1}
        to={(page) => `/pagination?page=${page}`}
      />
    </div>
  )
}
```

### Keyboard interaction

Pagination does not add collection-style arrow navigation. Each interactive page control uses the keyboard semantics of its rendered button, link, or custom polymorphic element.

## Examples

### Controlled page

```tsx
import { Badge, Pagination } from 'moraine'
import { createMemo, createSignal, For } from 'solid-js'

export function Controlled() {
  const [page, setPage] = createSignal(1)
  const itemsPerPage = 3

  const ALL_CUSTOMERS = [
    { id: 1, name: 'Stripe Inc.', plan: 'Enterprise', status: 'Active', mrr: '$2,400' },
    { id: 2, name: 'Vercel Labs', plan: 'Enterprise', status: 'Active', mrr: '$1,800' },
    { id: 3, name: 'Linear App', plan: 'Pro', status: 'Active', mrr: '$450' },
    { id: 4, name: 'Supabase Inc.', plan: 'Enterprise', status: 'Active', mrr: '$3,200' },
    { id: 5, name: 'Resend Co.', plan: 'Pro', status: 'Active', mrr: '$600' },
    { id: 6, name: 'Raycast HQ', plan: 'Pro', status: 'Trial', mrr: '$0' },
    { id: 7, name: 'Figma Design', plan: 'Enterprise', status: 'Active', mrr: '$4,500' },
    { id: 8, name: 'Midjourney AI', plan: 'Enterprise', status: 'Active', mrr: '$6,000' },
  ]

  const currentRecords = createMemo(() => {
    const start = (page() - 1) * itemsPerPage
    return ALL_CUSTOMERS.slice(start, start + itemsPerPage)
  })

  return (
    <div class="p-4 b-1 b-border rounded-xl bg-card max-w-xl space-y-4">
      <div class="flex items-center justify-between">
        <h4 class="text-sm font-semibold">Active Customers</h4>
        <span class="text-xs text-muted-foreground">
          Showing {(page() - 1) * itemsPerPage + 1}–
          {Math.min(page() * itemsPerPage, ALL_CUSTOMERS.length)} of {ALL_CUSTOMERS.length} records
        </span>
      </div>

      <div class="text-xs divide-border divide-y">
        <For each={currentRecords()}>
          {(customer) => (
            <div class="py-2.5 flex items-center justify-between">
              <div>
                <p class="text-foreground font-medium">{customer.name}</p>
                <p class="text-muted-foreground">{customer.plan} tier</p>
              </div>
              <div class="flex gap-3 items-center">
                <Badge variant={customer.status === 'Active' ? 'solid' : 'outline'} size="sm">
                  {customer.status}
                </Badge>
                <span class="font-medium font-mono">{customer.mrr}/mo</span>
              </div>
            </div>
          )}
        </For>
      </div>

      <div class="pt-2 border-t border-border flex justify-center">
        <Pagination
          page={page()}
          onPageChange={setPage}
          total={ALL_CUSTOMERS.length}
          itemsPerPage={itemsPerPage}
          siblingCount={1}
          prevText="Previous"
          nextText="Next"
        />
      </div>
    </div>
  )
}
```

### Page size and results

Keep the requested page within the new page count when users change how many results are shown.

```tsx
import { Field, Pagination, Select } from 'moraine'
import type { SelectT } from 'moraine'
import { createMemo, createSignal, For } from 'solid-js'

const RESULTS = [
  'Account settings',
  'Billing history',
  'Connected apps',
  'Developer keys',
  'Email preferences',
  'Export requests',
  'Member invitations',
  'Notification rules',
  'Privacy controls',
  'Team permissions',
  'Usage reports',
  'Workspace details',
]

const PAGE_SIZES: SelectT.Item<number>[] = [
  { value: 3, label: '3 results' },
  { value: 6, label: '6 results' },
  { value: 12, label: '12 results' },
]

export function PageSize() {
  const [page, setPage] = createSignal(1)
  const [pageSize, setPageSize] = createSignal(3)
  const visible = createMemo(() => {
    const start = (page() - 1) * pageSize()
    return RESULTS.slice(start, start + pageSize())
  })

  return (
    <div class="max-w-md w-full space-y-4">
      <Field label="Results per page">
        <Select
          items={PAGE_SIZES}
          value={pageSize()}
          onValueChange={(value) => {
            if (value === null) {
              return
            }
            setPageSize(value)
            setPage(1)
          }}
        />
      </Field>
      <ul class="text-sm border-y border-border divide-border divide-y">
        <For each={visible()}>{(result) => <li class="py-2">{result}</li>}</For>
      </ul>
      <Pagination
        total={RESULTS.length}
        itemsPerPage={pageSize()}
        page={page()}
        onPageChange={setPage}
      />
    </div>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-disabled` | `pagination-item`, `pagination-prev`, `pagination-next` | Present when the component, slot, or item is disabled. |
| `data-loading` | `pagination-item`, `pagination-prev`, `pagination-next` | Present when the component or async operation is loading. |
| `data-text` | `pagination-prev`, `pagination-next` | Present when the input group part contains text. |
| `data-current` | `pagination-item` | Present when the item represents the current location or step. |
| `data-ellipsis` | `pagination-ellipsis` | — |

## Props

Props for the Pagination component.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| activeVariant | 'default' \| 'secondary' \| 'outline' \| 'ghost' \| 'link' \| 'destructive' \| undefined | 'outline' | Visual variant for the active page button. |
| controlAs | ValidComponent \| undefined | — | Component used to render previous and next controls. |
| controlVariant | 'default' \| 'secondary' \| 'outline' \| 'ghost' \| 'link' \| 'destructive' \| undefined | 'ghost' | Visual variant for the previous/next control buttons. |
| defaultPage | number \| undefined | 1 | Initial page number when uncontrolled. |
| disabled | boolean \| undefined | — | Whether the pagination is disabled. |
| ellipsisIcon | IconT.Name \| undefined | 'icon-ellipsis' | Icon name for the ellipsis indicator. |
| itemAs | ValidComponent \| undefined | — | Component used to render numbered page items. |
| itemsPerPage | number \| undefined | 10 | Number of items to display per page. |
| nextIcon | IconT.Name \| undefined | 'icon-chevron-right' | Icon name for the next button. |
| nextText | string \| undefined | — | Text to display in the next button. |
| onPageChange | ((page: number) => void) \| undefined | — | Callback triggered when the page changes. |
| page | number \| undefined | — | Controlled current page number (1-indexed). |
| prevIcon | IconT.Name \| undefined | 'icon-chevron-left' | Icon name for the previous button. |
| prevText | string \| undefined | — | Text to display in the previous button. |
| ref | Ref<HTMLElement> \| undefined | — | Ref forwarded to the root `<nav>` element. |
| showControls | boolean \| undefined | true | Whether to show previous and next control buttons. |
| siblingCount | number \| undefined | 2 | Number of page buttons to show on either side of the current page.<br>Finite integer values are clamped between 0 and 100. |
| size | 'sm' \| 'md' \| 'lg' \| undefined | 'md' | Size of the pagination buttons. |
| to | ((page: number) => string \| undefined) \| undefined | — | Function to generate a destination URL for a given page number.<br>Without a custom component, enabled pagination controls with a destination render as anchors. |
| total | number \| undefined | 0 | Total number of items across all pages. |
| variant | 'default' \| 'secondary' \| 'outline' \| 'ghost' \| 'link' \| 'destructive' \| undefined | 'ghost' | Visual variant for the page buttons. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | Classes \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | Styles \| undefined | — | Family slot style defaults for this instance. |
