FairGarden Design

Pagination

Moving through long paged lists: Previous and Next around the page numbers, or around a "Page 5 of 12" count.

import { Pagination } from '@fairgarden-private/design/components/Pagination'

Pass the current page, the page count and getHref, which gives every page its own URL; renderLink builds each anchor, for example (href) => <NextLink href={href} />. Previous and Next are text Buttons rendered as anchors, in type-label caps with the chevron_left and chevron_right glyphs at the inline tier. Page numbers are Link kind="nav" in mono at label size with tabular figures, so a changing count never shifts the row. The current page is unlinked, bold and barred. On the first and last pages the Previous or Next control is omitted and its space kept, never shown disabled. While the list loads, mark the list region aria-busy; the pager stays put.

Numbered, compact, step, dots and more

Numbered, compact, step, dots and more

numbered (the default) reflows on its own width: below 768 px it shows the compact count; from 768 px the page numbers with one sibling on each side of the current page; from 1024 px two. compact keeps the count at every width, for lists over 50 pages. Below 360 px the words drop and the chevrons stand alone, still named "Previous" and "Next". The root is an inline-size container, so give it a width in shrink-to-fit layouts. Drag the frame's corner to watch the reflow.

step is the pager for carousels and figures: icon-only ‹ › circles around a mono "3 of 12". Pass onPageChange to handle the buttons, getHref to make them links, or both. dots adds up to 8 position dots under the same count, the current one solid and larger; with more than 8 it is the plain step pager. Neither prints. more is for feeds: "Showing 20 of 54" over an outline "Show More" Button that links to the next page, so every step has a URL; pass items for the count. Page numbers and the Previous and Next words take the bare-text underline on hover, and the chevrons their heavier weight.

numbered

compact

step

dots

more

PaginationForms.tsx
'use client'

import * as React from 'react'
import { Pagination } from '@fairgarden-private/design/components/Pagination'
import styles from './forms.module.css'

/** Keeps the demo on this page: a click on a page link sets the page instead of navigating. */
function usePager(initial: number) {
  const [page, setPage] = React.useState(initial)
  const onClick = (event: React.MouseEvent<HTMLElement>) => {
    const anchor = (event.target as HTMLElement).closest('a')
    const match = anchor?.hash.match(/^#page-(\d+)$/)
    if (match) {
      event.preventDefault()
      setPage(Number(match[1]))
    }
  }
  return { page, onClick }
}

const href = (page: number) => `#page-${page}`

export function PaginationForms() {
  const numbered = usePager(5)
  const compact = usePager(5)
  const more = usePager(1)
  const [step, setStep] = React.useState(3)
  const [dots, setDots] = React.useState(2)

  return (
    <div className={styles.stack}>
      <p className={styles.name}>numbered</p>
      <div className={styles.frame}>
        <Pagination
          page={numbered.page}
          count={12}
          getHref={href}
          onClick={numbered.onClick}
          items={{
            first: (numbered.page - 1) * 10 + 1,
            last: Math.min(numbered.page * 10, 118),
            total: 118,
          }}
        />
      </div>
      <p className={styles.name}>compact</p>
      <div className={styles.frame}>
        <Pagination
          kind="compact"
          page={compact.page}
          count={60}
          getHref={href}
          onClick={compact.onClick}
        />
      </div>
      <p className={styles.name}>step</p>
      <div className={styles.frame}>
        <Pagination kind="step" page={step} count={12} onPageChange={setStep} />
      </div>
      <p className={styles.name}>dots</p>
      <div className={styles.frame}>
        <Pagination
          kind="dots"
          page={dots}
          count={6}
          onPageChange={setDots}
          previousLabel="Previous photo"
          nextLabel="Next photo"
        />
      </div>
      <p className={styles.name}>more</p>
      <div className={styles.frame}>
        <Pagination
          kind="more"
          page={more.page}
          count={3}
          getHref={href}
          onClick={more.onClick}
          items={{ first: 1, last: Math.min(more.page * 20, 54), total: 54 }}
        />
      </div>
    </div>
  )
}

On grounds

On page grounds and a deep field

The chevrons take the glyph role: the accent on a page ground, the text ink on a deep field. Lists belong on reading grounds, so saturated fields are rare.

paper

tide

forest

PaginationGrounds.tsx
'use client'

import * as React from 'react'
import { Ground } from '@fairgarden-private/design/components/Ground'
import { Pagination } from '@fairgarden-private/design/components/Pagination'
import {
  isFieldPreset,
  isPageGroundPreset,
  presets,
  type GroundPreset,
} from '@fairgarden-private/design/utils/scope'
import styles from './grounds.module.css'

/** The first preset that passes `test`, read from the preset table, never named here. */
function firstPreset(test: (preset: GroundPreset) => boolean): GroundPreset | undefined {
  return (Object.keys(presets) as GroundPreset[]).find(test)
}

/** A sample surface: a field preset as an inset field, a page ground as a face. */
function Sample({
  preset,
  className,
  children,
}: {
  preset: GroundPreset
  className: string
  children: React.ReactNode
}) {
  if (isFieldPreset(preset)) {
    return (
      <Ground kind="field" preset={preset} className={className}>
        {children}
      </Ground>
    )
  }
  if (isPageGroundPreset(preset)) {
    return (
      <Ground kind="face" preset={preset} className={className}>
        {children}
      </Ground>
    )
  }
  return null
}

/** A light page ground, a pastel and a deep field; lists rarely sit on saturated fields. */
const grounds = [
  firstPreset((preset) => presets[preset].tone === 'light-base'),
  firstPreset((preset) => presets[preset].tone === 'tinted'),
  firstPreset((preset) => isFieldPreset(preset) && presets[preset].mode === 'always-dark'),
].filter((preset): preset is GroundPreset => preset != null)

const href = (page: number) => `#grounds-${page}`

export function PaginationGrounds() {
  return (
    <div className={styles.stack}>
      {grounds.map((preset) => (
        <Sample key={preset} preset={preset} className={styles.face}>
          <p className={styles.name}>{preset}</p>
          <Pagination kind="compact" page={3} count={12} getHref={href} />
        </Sample>
      ))}
    </div>
  )
}

In print the pager is hidden and its links print nothing. Pass items to print one state line in its place, "Items 41–50 of 118.", and fullListHref to follow it with the full list's link.

API Reference

A nav named “Pagination”. numbered and compact: Previous at the start, the page numbers (or the “Page 5 of 12″ status) centered, Next at the end; Previous and Next are type-label caps with Material Symbols chevron_left / chevron_right at the inline tier; page numerals are mono at type-label size with tabular figures, so a changing count never shifts the row [D165, D174, D175]. The current page is unlinked, bold and barred. step and dots: icon-only ‹ › circles around a mono “3 of 12″ (plus up to 8 dots). more: “Showing 20 of 54″ over a “Show More” Button. At the ends a control is omitted and its space kept, never disabled. The pager is hidden in print; with items the linked forms print one state line instead.

PropTypeDescription
count
number

The page (or item) count.

fullListHref
string | undefined

The full list’s URL, printed after the state line per §7.6 (“Full list (example.org/birds)").

getHref
((page: number) => string) | undefined

Every page’s URL: each page is a link, never a click handler alone (P8).

items
PaginationItems | undefined

The items on this page. Given, the pager prints “Items 41–50 of 118.” in its place.

kind
| 'numbered'
| 'compact'
| 'step'
| 'dots'
| 'more'
| undefined

numbered (default): ‹ Previous · 1 … 4 5 6 … 12 · Next ›, shown from 768 px of its own width and compact below. compact: ‹ Previous · Page 5 of 12 · Next › at every width, for lists over 50 pages.

moreLabel
string | undefined
nextLabel
string | undefined

The Next label, authored in sentence case; the CSS sets the caps [D165]. On the step pager it is the › button’s accessible name. Default “Next”.

onPageChange
((page: number) => void) | undefined
page
number

The current page (or item), 1-based.

previousLabel
string | undefined

The Previous label, authored in sentence case; the CSS sets the caps [D165]. On the step pager it is the ‹ button’s accessible name. Default “Previous”.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: numerals, labels, the current bar and the focus ring. Never defaulted; omitted, it inherits the scope [D133].

renderLink
((href: string) => ReactElement) | undefined

Builds each anchor, e.g. (href) => <NextLink href={href} />. Default: a plain <a href>.

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: the Previous and Next arrows on light grounds. Never defaulted.

pagination
PaginationItems

The items on the current page, for the printed state line (§7.12.1).

type PaginationItems = {
  /** The first item's position on this page, e.g. 41. */
  first: number;
  /** The last item's position on this page, e.g. 50. */
  last: number;
  /** The item count across every page, e.g. 118. */
  total: number;
}
PaginationKind
type PaginationKind = 'numbered' | 'compact' | 'step' | 'dots' | 'more'<
  'numbered' | 'compact' | 'step' | 'dots' | 'more' | null | undefined
>
PaginationProps

Props for Pagination: nav props, the page, the count, the form and how it moves, and the color axes.

type PaginationProps = (
  | {
      kind?: 'numbered' | 'compact';
      getHref: (page: number) => string;
      onPageChange?: undefined;
      moreLabel?: undefined;
    }
  | {
      kind: 'step' | 'dots';
      getHref?: (page: number) => string;
      onPageChange?: (page: number) => void;
      moreLabel?: undefined;
    }
  | {
      kind: 'more';
      getHref: (page: number) => string;
      moreLabel?: string;
      onPageChange?: undefined;
    }
) & {
  /** The current page (or item), 1-based. */
  page: number;
  /** The page (or item) count. */
  count: number;
  /**
   * Builds each anchor, e.g. `(href) => <NextLink href={href} />`. Default: a
   * plain `<a href>`.
   */
  renderLink?: (href: string) => ReactElement;
  /**
   * Primary Radix scale: numerals, labels, the current bar and the focus
   * ring. Never defaulted; omitted, it inherits the scope [D133].
   */
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  /** Secondary Radix scale: the Previous and Next arrows on light grounds. Never defaulted. */
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
  /**
   * The Previous label, authored in sentence case; the CSS sets the caps
   * [D165]. On the step pager it is the ‹ button's accessible name. Default
   * "Previous".
   */
  previousLabel?: string;
  /**
   * The Next label, authored in sentence case; the CSS sets the caps
   * [D165]. On the step pager it is the › button's accessible name. Default
   * "Next".
   */
  nextLabel?: string;
  /** The items on this page. Given, the pager prints "Items 41–50 of 118." in its place. */
  items?: PaginationItems;
  /** The full list's URL, printed after the state line per §7.6 ("Full list (example.org/birds)"). */
  fullListHref?: string;
}
PaginationVariants
type PaginationVariants = {
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
  kind?: 'numbered' | 'compact' | 'step' | 'dots' | 'more' | null;
}

Specification: DESIGN-SYSTEM.md §9.9 (pagination) and §5.10.2 (reflow), with D165, D174 and D175.