Navigation

Pagination

Seven-slot pager that turns compact when space runs out

Preview

When its container gets narrow, the row turns into a label like "Page 6 of 12", whose text comes from the formatLabel prop. The size sets the threshold (392 px for md, 320 for sm) and the change of shape is CSS only. Without the row, variant and shape don't show, because they only style its numbers.

size
variant
shape
align
total

Code

import css from "./Pagination.module.css";
import { useEffect, useRef } from "react";

import { CaretLeftIcon } from "@phosphor-icons/react/dist/csr/CaretLeft";
import { CaretRightIcon } from "@phosphor-icons/react/dist/csr/CaretRight";

type Size = "sm" | "md";
type Align = "start" | "center" | "end";
type Item = number | "start-ellipsis" | "end-ellipsis";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface ResponsiveAlign {
  base: Align;
  mobile?: Align;
  tablet?: Align;
  desktop?: Align;
  wide?: Align;
}

interface PaginationProps {
  size: Size | ResponsiveSize;
  variant: "outline" | "solid" | "soft";
  shape: "rounded" | "circular";
  align: Align | ResponsiveAlign;
  page: number;
  total: number;
  "aria-label": string;
  previousAriaLabel: string;
  nextAriaLabel: string;
  pageAriaLabel: (page: number, current: boolean) => string;
  formatLabel: (page: number, total: number) => string;
  onChange: (page: number) => void;
}

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

function getAlignClasses(align: Align | ResponsiveAlign): string {
  if (typeof align === "string") {
    return css[align];
  }

  const classes = [css[`${align.base}_base`]];

  if (align.mobile) classes.push(css[`${align.mobile}_mobile`]);
  if (align.tablet) classes.push(css[`${align.tablet}_tablet`]);
  if (align.desktop) classes.push(css[`${align.desktop}_desktop`]);
  if (align.wide) classes.push(css[`${align.wide}_wide`]);

  return classes.join(" ");
}

// Seven slots whatever the page, so the arrows never move under the pointer.
// An ellipsis always hides two pages or more: hiding one would save no room.
function getItems(page: number, total: number): Item[] {
  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
  if (page <= 4) return [1, 2, 3, 4, 5, "end-ellipsis", total];
  if (page >= total - 3) {
    return [1, "start-ellipsis", total - 4, total - 3, total - 2, total - 1, total];
  }
  return [1, "start-ellipsis", page - 1, page, page + 1, "end-ellipsis", total];
}

export default function Pagination({
  size,
  variant,
  shape,
  align,
  page,
  total,
  "aria-label": ariaLabel,
  previousAriaLabel,
  nextAriaLabel,
  pageAriaLabel,
  formatLabel,
  onChange,
}: PaginationProps) {
  const previous = useRef<HTMLButtonElement>(null);
  const next = useRef<HTMLButtonElement>(null);
  const pendingFocus = useRef<HTMLButtonElement | null>(null);

  const select = (target: number) => {
    if (target === page) return;
    onChange(target);
  };

  // A disabled button drops focus. The opposite arrow is the one control both
  // layouts share: the compact one hides the page buttons.
  const step = (delta: number) => {
    const target = page + delta;
    if (target === 1) pendingFocus.current = next.current;
    if (target === total) pendingFocus.current = previous.current;
    select(target);
  };

  useEffect(() => {
    pendingFocus.current?.focus();
    pendingFocus.current = null;
  }, [page]);

  return (
    <nav
      className={`${css.pagination} ${getSizeClasses(size)} ${css[variant]} ${css[shape]} ${getAlignClasses(align)}`}
      aria-label={ariaLabel}
    >
      <div className={css.pagination_row}>
        <button
          ref={previous}
          type="button"
          className={css.row_arrow}
          aria-label={previousAriaLabel}
          disabled={page <= 1}
          onClick={() => step(-1)}
        >
          <CaretLeftIcon weight="bold" />
        </button>

        <ul className={css.row_list}>
          {getItems(page, total).map((item) =>
            typeof item === "number" ? (
              <li key={item}>
                <button
                  type="button"
                  className={css.list_page}
                  aria-label={pageAriaLabel(item, item === page)}
                  aria-current={item === page ? "page" : undefined}
                  onClick={() => select(item)}
                >
                  {item}
                </button>
              </li>
            ) : (
              <li key={item} className={css.list_ellipsis} aria-hidden="true">
                …
              </li>
            ),
          )}
        </ul>

        <p className={css.row_label} aria-hidden="true">
          {formatLabel(page, total)}
        </p>

        {/* Focus stays on the arrow after a step, so nothing else would say where it landed. */}
        <p className={css.row_status} aria-live="polite">
          {formatLabel(page, total)}
        </p>

        <button
          ref={next}
          type="button"
          className={css.row_arrow}
          aria-label={nextAriaLabel}
          disabled={page >= total}
          onClick={() => step(1)}
        >
          <CaretRightIcon weight="bold" />
        </button>
      </div>
    </nav>
  );
}

Props

PropTypeRequiredDescription
size"sm" | "md" | ResponsiveSizerequiredSide of each box (32/40). It also sets the width the full row needs (a 320/392px container, with pages of up to two digits); below it the row turns into the label. Fixed or responsive.
variant"outline" | "solid" | "soft"requiredHow the current page is marked: outline, solid fill or a faint fill.
shape"rounded" | "circular"requiredBox shape: rounded corners or a circle, which stretches into a pill when the number does not fit (three digits at sm, four at md).
align"start" | "center" | "end" | ResponsiveAlignrequiredAligns the row within its own slot, which it fills because it turns compact by that width; it does not place the component. The compact label follows it. Under a data table it goes to the end, with the range on the other side. Fixed or responsive.
totalnumberrequiredNumber of pages. Up to 7 they all show; past that, seven fixed slots with ellipses.
aria-labelstringrequiredName of the <nav> for screen readers; it tells this navigation apart from the others on the page.
previousAriaLabelstringrequiredName of the previous arrow for screen readers; it only draws an icon.
nextAriaLabelstringrequiredName of the next arrow for screen readers; it only draws an icon.
pageAriaLabel(page: number, current: boolean) => stringrequiredName of each page for screen readers. A function because the text carries the number and the library has no language.
formatLabel(page: number, total: number) => stringrequiredText of the compact label ("Page 3 of 12"). Screen readers also announce it on every change.
pagenumberrequiredCurrent page (controlled), starting at 1.
onChange(page: number) => voidrequiredCallback with the chosen page. It does not fire for the current one.