Skip to content
  1. Navigation
  2. Pagination

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 = <>&hellip;</>,
  ...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

PropDefaultDescription
Paginationrenders a <nav> element
aria-labelPage navigationName 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.
disabledfalseDisables 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.
disabledfalseDisables 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.
currentfalseShades 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.
PropDefaultDescription
Paginationrenders a <nav> element
ariaLabelPage navigationName 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.
disabledfalseDisables 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.
disabledfalseDisables 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.
currentfalseShades 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>