Pagination
Previous, next and numbered links for moving through a long list of records.
import {
Pagination,
PaginationGap,
PaginationList,
PaginationNext,
PaginationPage,
PaginationPrevious,
} from '@/components/pagination'
export default function Example() {
return (
<Pagination>
<PaginationPrevious href="/invoices?page=6" />
<PaginationList>
<PaginationPage href="/invoices?page=1">1</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=6">6</PaginationPage>
<PaginationPage href="/invoices?page=7" current>
7
</PaginationPage>
<PaginationPage href="/invoices?page=8">8</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=38">38</PaginationPage>
</PaginationList>
<PaginationNext href="/invoices?page=8" />
</Pagination>
)
}<script setup lang="ts">
import Pagination from '@/components/pagination.vue'
import PaginationGap from '@/components/pagination-gap.vue'
import PaginationList from '@/components/pagination-list.vue'
import PaginationNext from '@/components/pagination-next.vue'
import PaginationPage from '@/components/pagination-page.vue'
import PaginationPrevious from '@/components/pagination-previous.vue'
</script>
<template>
<Pagination>
<PaginationPrevious href="/invoices?page=6" />
<PaginationList>
<PaginationPage href="/invoices?page=1">1</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=6">6</PaginationPage>
<PaginationPage href="/invoices?page=7" current>7</PaginationPage>
<PaginationPage href="/invoices?page=8">8</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=38">38</PaginationPage>
</PaginationList>
<PaginationNext href="/invoices?page=8" />
</Pagination>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
import { Button } from './button'
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/16/solid'
export function Pagination({
'aria-label': ariaLabel = 'Page navigation',
className,
...props
}: React.ComponentPropsWithoutRef<'nav'>) {
return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
}
type StepProps = React.PropsWithChildren<{
href?: string | null
onClick?: React.MouseEventHandler<HTMLElement>
disabled?: boolean
className?: string
}>
function stepTarget({ href, onClick, disabled }: StepProps) {
if (disabled || (!href && !onClick)) return { disabled: true }
return href ? { href, onClick } : { onClick }
}
export function PaginationPrevious({ className, children = 'Previous', ...props }: StepProps) {
return (
<span className={clsx(className, 'grow basis-0')}>
<Button {...stepTarget(props)} plain>
<ArrowLeftIcon />
{children}
</Button>
</span>
)
}
export function PaginationNext({ className, children = 'Next', ...props }: StepProps) {
return (
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
<Button {...stepTarget(props)} plain>
{children}
<ArrowRightIcon />
</Button>
</span>
)
}
export function PaginationList({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
return <span {...props} className={clsx(className, 'flex items-baseline gap-x-2')} />
}
type PageProps = React.PropsWithChildren<{
href?: string
className?: string
current?: boolean
onClick?: React.MouseEventHandler<HTMLElement>
}>
export function PaginationPage({ href, className, current = false, onClick, children }: PageProps) {
let pageNumber = typeof children === 'string' || typeof children === 'number' ? children : null
return (
<Button
{...(href && { href })}
onClick={onClick}
plain
aria-label={pageNumber === null ? undefined : `Page ${pageNumber}`}
aria-current={current ? 'page' : undefined}
className={clsx(
className,
'min-w-9',
'before:absolute before:-inset-px before:rounded-lg',
current ? ['before:bg-zinc-950/5', 'dark:before:bg-white/10'] : 'max-sm:hidden'
)}
>
<span className="-mx-0.5">{children}</span>
</Button>
)
}
export function PaginationGap({
className,
children = <>…</>,
...props
}: React.ComponentPropsWithoutRef<'span'>) {
return (
<span
aria-hidden="true"
{...props}
className={clsx(className, 'w-9 max-sm:hidden text-center select-none text-sm/6 font-semibold text-zinc-950 dark:text-white')}
>
{children}
</span>
)
}Also copy Button.
<script setup lang="ts">
withDefaults(defineProps<{ ariaLabel?: string }>(), { ariaLabel: 'Page navigation' })
</script>
<template>
<nav :aria-label="ariaLabel" class="flex gap-x-2">
<slot />
</nav>
</template>Also copy Button.
Component API
| Prop | Default | Description |
|---|---|---|
Paginationrenders a <nav> element | ||
aria-label | Page navigation | Name announced for the navigation landmark. |
PaginationPreviousrenders a <span> element | ||
href | - | Where the link goes. Without `href` or `onClick`, the step renders disabled. |
onClick | - | Handles the step in place of a link, when the records are already loaded. |
disabled | false | Disables the step, on the first or last page of a list paged with `onClick`. |
PaginationNextrenders a <span> element | ||
href | - | Where the link goes. Without `href` or `onClick`, the step renders disabled. |
onClick | - | Handles the step in place of a link, when the records are already loaded. |
disabled | false | Disables the step, on the first or last page of a list paged with `onClick`. |
PaginationListrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
PaginationPagebuilt on the <Button> component | ||
href | - | Where the page link goes. Leave it out and handle `onClick` when the records are already loaded. |
current | false | Shades the page the user is looking at. Below `sm`, it is the only page left in the list. |
onClick | - | Handles the page in place of a link. |
PaginationGaprenders a <span> element | ||
| This component does not expose any component-specific props. | ||
| Prop | Default | Description |
|---|---|---|
Paginationrenders a <nav> element | ||
ariaLabel | Page navigation | Name announced for the navigation landmark. |
PaginationPreviousrenders a <span> element | ||
href | - | Where the link goes. Without `href` or `@click`, the step renders disabled. |
@click | - | Handles the step in place of a link, when the records are already loaded. |
disabled | false | Disables the step, on the first or last page of a list paged with `@click`. |
PaginationNextrenders a <span> element | ||
href | - | Where the link goes. Without `href` or `@click`, the step renders disabled. |
@click | - | Handles the step in place of a link, when the records are already loaded. |
disabled | false | Disables the step, on the first or last page of a list paged with `@click`. |
PaginationListrenders a <span> element | ||
| This component does not expose any component-specific props. | ||
PaginationPagebuilt on the <Button> component | ||
href | - | Where the page link goes. Leave it out and listen to `@click` when the records are already loaded. |
current | false | Shades the page the user is looking at. Below `sm`, it is the only page left in the list. |
@click | - | Handles the page in place of a link. |
PaginationGaprenders a <span> element | ||
| This component does not expose any component-specific props. | ||
Usage
Nobody needs a link to every page. Show the first and last pages plus the neighbors of the current one, and let a gap stand for everything in between.
Number the pages of a list
PaginationPrevious and PaginationNext frame a PaginationList of PaginationPage links. Mark the open page with current and fill the skipped ranges with PaginationGap:
import {
Pagination,
PaginationGap,
PaginationList,
PaginationNext,
PaginationPage,
PaginationPrevious,
} from '@/components/pagination'
export default function Example() {
return (
<Pagination>
<PaginationPrevious href="/invoices?page=6" />
<PaginationList>
<PaginationPage href="/invoices?page=1">1</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=6">6</PaginationPage>
<PaginationPage href="/invoices?page=7" current>
7
</PaginationPage>
<PaginationPage href="/invoices?page=8">8</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=38">38</PaginationPage>
</PaginationList>
<PaginationNext href="/invoices?page=8" />
</Pagination>
)
}<script setup lang="ts">
import Pagination from '@/components/pagination.vue'
import PaginationGap from '@/components/pagination-gap.vue'
import PaginationList from '@/components/pagination-list.vue'
import PaginationNext from '@/components/pagination-next.vue'
import PaginationPage from '@/components/pagination-page.vue'
import PaginationPrevious from '@/components/pagination-previous.vue'
</script>
<template>
<Pagination>
<PaginationPrevious href="/invoices?page=6" />
<PaginationList>
<PaginationPage href="/invoices?page=1">1</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=6">6</PaginationPage>
<PaginationPage href="/invoices?page=7" current>7</PaginationPage>
<PaginationPage href="/invoices?page=8">8</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=38">38</PaginationPage>
</PaginationList>
<PaginationNext href="/invoices?page=8" />
</Pagination>
</template>Stop at the first page
Leave href off PaginationPrevious and it renders disabled. The button stays in place, so the numbers do not shift when the user comes back to page 1:
<Pagination>
<PaginationPrevious />
<PaginationList>
<PaginationPage href="/invoices?page=1" current>
1
</PaginationPage>
<PaginationPage href="/invoices?page=2">2</PaginationPage>
<PaginationPage href="/invoices?page=3">3</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=38">38</PaginationPage>
</PaginationList>
<PaginationNext href="/invoices?page=2" />
</Pagination><Pagination>
<PaginationPrevious />
<PaginationList>
<PaginationPage href="/invoices?page=1" current>1</PaginationPage>
<PaginationPage href="/invoices?page=2">2</PaginationPage>
<PaginationPage href="/invoices?page=3">3</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=38">38</PaginationPage>
</PaginationList>
<PaginationNext href="/invoices?page=2" />
</Pagination>Stop at the last page
The same goes for PaginationNext once the user reaches the end of the list:
<Pagination>
<PaginationPrevious href="/invoices?page=37" />
<PaginationList>
<PaginationPage href="/invoices?page=1">1</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=36">36</PaginationPage>
<PaginationPage href="/invoices?page=37">37</PaginationPage>
<PaginationPage href="/invoices?page=38" current>
38
</PaginationPage>
</PaginationList>
<PaginationNext />
</Pagination><Pagination>
<PaginationPrevious href="/invoices?page=37" />
<PaginationList>
<PaginationPage href="/invoices?page=1">1</PaginationPage>
<PaginationGap />
<PaginationPage href="/invoices?page=36">36</PaginationPage>
<PaginationPage href="/invoices?page=37">37</PaginationPage>
<PaginationPage href="/invoices?page=38" current>38</PaginationPage>
</PaginationList>
<PaginationNext />
</Pagination>Composition
Every part is optional apart from Pagination itself. Drop the numbers when the total is unknown, and change the wording of the steps when the list has a natural order such as time.
Page through a cursor-based API
An API that returns a cursor instead of a page count cannot tell you how many pages exist. Keep only the two steps and build their href from the cursors of the first and last records:
<Pagination>
<PaginationPrevious href="/payments?ending_before=pay_7Hq2Lm" />
<PaginationNext href="/payments?starting_after=pay_3Xk9Tb" />
</Pagination><Pagination>
<PaginationPrevious href="/payments?ending_before=pay_7Hq2Lm" />
<PaginationNext href="/payments?starting_after=pay_3Xk9Tb" />
</Pagination>Rename the steps
Text passed as children replaces Previous and Next. In an event log sorted newest first, Newer and Older say more about where each step leads:
<Pagination>
<PaginationPrevious href="/developers/logs?before=evt_91c">Newer events</PaginationPrevious>
<PaginationNext href="/developers/logs?after=evt_4f0">Older events</PaginationNext>
</Pagination><Pagination>
<PaginationPrevious href="/developers/logs?before=evt_91c">Newer events</PaginationPrevious>
<PaginationNext href="/developers/logs?after=evt_4f0">Older events</PaginationNext>
</Pagination>Responsive behavior
Below the sm breakpoint, PaginationList keeps only the current page, centered between the two steps. The other numbers and the gaps come back from sm up.
Accessibility
The component renders a nav landmark. Each number is announced as "Page 7" rather than a lone digit, the current one carries aria-current="page", and the gaps are hidden from assistive technology. The steps are announced by their visible text, so the words you pass as children are the words a screen reader says.
Name each pagination on the page
The landmark is called Page navigation by default. When a page holds more than one list, pass aria-label so each landmark says which list it moves through:
The landmark is called Page navigation by default. When a page holds more than one list, set aria-label (the ariaLabel prop) so each landmark says which list it moves through:
<Pagination aria-label="Customer pages">
<PaginationPrevious href="/customers?page=2" />
<PaginationList>
<PaginationPage href="/customers?page=1">1</PaginationPage>
<PaginationPage href="/customers?page=2">2</PaginationPage>
<PaginationPage href="/customers?page=3" current>
3
</PaginationPage>
<PaginationPage href="/customers?page=4">4</PaginationPage>
<PaginationPage href="/customers?page=5">5</PaginationPage>
</PaginationList>
<PaginationNext href="/customers?page=4" />
</Pagination><Pagination aria-label="Customer pages">
<PaginationPrevious href="/customers?page=2" />
<PaginationList>
<PaginationPage href="/customers?page=1">1</PaginationPage>
<PaginationPage href="/customers?page=2">2</PaginationPage>
<PaginationPage href="/customers?page=3" current>3</PaginationPage>
<PaginationPage href="/customers?page=4">4</PaginationPage>
<PaginationPage href="/customers?page=5">5</PaginationPage>
</PaginationList>
<PaginationNext href="/customers?page=4" />
</Pagination>