Pagination

Navigation between pages of a list.

Installation

Terminal
pnpm dlx @dowel-ui/cli add pagination

Installs button as well, because this component imports it.

npm packages installed: radix-ui.

Accessibility

A named nav landmark. The current page carries aria-current="page" — styling it differently conveys nothing on its own. Links by default, since a page of results is a place worth sharing; use asChild for your router's link, or render buttons when paging only changes client state. The optional sliding pill (indicator="slide") is an aria-hidden list item, so the list's item count stays the number of pages, and it stops sliding under reduced motion.

Props

Pagination

Plus every attribute of <nav>.

PaginationContent

PropTypeDefault
indicator

slide moves one active-page pill between pages instead of restyling each link. Worth it when paging changes client state; a link that loads a new document never sees the slide. Default none.

"none" | "slide""none"

Plus every attribute of <ul>.

PaginationItem

Plus every attribute of <li>.

PaginationLink

PropTypeDefault
isActive

Marks the page the user is on. Sets aria-current="page".

boolean
size"sm" | "md" | "icon" | "icon-sm""icon"
asChildboolean

Plus every attribute of <a>.

PaginationPrevious

PropTypeDefault
isActive

Marks the page the user is on. Sets aria-current="page".

boolean
size"sm" | "md" | "icon" | "icon-sm"
asChildboolean

Plus every attribute of <a>.

PaginationNext

PropTypeDefault
isActive

Marks the page the user is on. Sets aria-current="page".

boolean
size"sm" | "md" | "icon" | "icon-sm"
asChildboolean

Plus every attribute of <a>.

PaginationEllipsis

Plus every attribute of <span>.

Quality

7/7 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokensdoes not apply
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add pagination writes into your project, with imports rewritten to your own path alias.

ui/pagination.tsx
// Motion from SmoothUI Pagination (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { Slot } from "radix-ui";
import type { ComponentPropsWithRef } from "react";

import { buttonVariants } from "@/components/button";
import { mirrorForDirection } from "@/lib/styles";
import { cn } from "@/lib/utils";

import { PaginationIndicator } from "./pagination-indicator";

/**
 * Navigation between pages of a list.
 *
 * Rendered as links by default, not buttons: a page of results is a place, so
 * it should be shareable, openable in a new tab and part of history. Use
 * `asChild` to hand off to your router's link. For pagination that only changes
 * client state, render buttons instead — `PaginationLink` accepts either.
 */
export function Pagination({ className, ...props }: ComponentPropsWithRef<"nav">) {
  return (
    <nav
      // A named landmark, so it can be jumped to and is distinguishable from
      // the page's main navigation.
      aria-label="Pagination"
      data-slot="pagination"
      className={cn("mx-auto flex w-full justify-center", className)}
      {...props}
    />
  );
}

export interface PaginationContentProps extends ComponentPropsWithRef<"ul"> {
  /**
   * `slide` moves one active-page pill between pages instead of restyling
   * each link. Worth it when paging changes client state; a link that loads
   * a new document never sees the slide. Default `none`.
   */
  indicator?: "none" | "slide";
}

export function PaginationContent({
  className,
  indicator = "none",
  children,
  ...props
}: PaginationContentProps) {
  const slide = indicator === "slide";
  return (
    <ul
      data-slot="pagination-content"
      data-indicator={slide ? "slide" : undefined}
      className={cn(
        "flex flex-row items-center gap-1",
        slide && [
          "relative",
          // Once the pill has measured, the active link hands its border and
          // background to it. Before that it keeps its own, so first paint and
          // a server render look exactly as they always have.
          "has-[>[data-slot=pagination-indicator][data-ready]]:[&_[data-slot=pagination-link]]:relative",
          "has-[>[data-slot=pagination-indicator][data-ready]]:[&_[data-slot=pagination-link][aria-current=page]]:border-transparent",
          "has-[>[data-slot=pagination-indicator][data-ready]]:[&_[data-slot=pagination-link][aria-current=page]]:bg-transparent",
        ],
        className,
      )}
      {...props}
    >
      {slide ? <PaginationIndicator /> : null}
      {children}
    </ul>
  );
}

export function PaginationItem({ className, ...props }: ComponentPropsWithRef<"li">) {
  return <li data-slot="pagination-item" className={cn(className)} {...props} />;
}

export interface PaginationLinkProps extends ComponentPropsWithRef<"a"> {
  /** Marks the page the user is on. Sets aria-current="page". */
  isActive?: boolean;
  size?: "sm" | "md" | "icon" | "icon-sm";
  asChild?: boolean;
}

export function PaginationLink({
  className,
  isActive,
  size = "icon",
  asChild,
  ...props
}: PaginationLinkProps) {
  const Comp = asChild ? Slot.Root : "a";

  return (
    <Comp
      data-slot="pagination-link"
      // aria-current is what tells a screen reader user where they are; styling
      // the active page differently does nothing on its own.
      aria-current={isActive ? "page" : undefined}
      data-active={isActive || undefined}
      className={cn(
        buttonVariants({ variant: isActive ? "outline" : "ghost", size }),
        isActive && "border-border-strong font-medium",
        className,
      )}
      {...props}
    />
  );
}

export function PaginationPrevious({ className, ...props }: PaginationLinkProps) {
  return (
    <PaginationLink
      aria-label="Go to previous page"
      size="sm"
      className={cn("gap-1 px-2.5", className)}
      {...props}
    >
      <svg
        viewBox="0 0 24 24"
        fill="none"
        aria-hidden="true"
        className={cn("size-4", mirrorForDirection)}
      >
        <path
          d="m15 18-6-6 6-6"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
      <span className="hidden sm:inline">Previous</span>
    </PaginationLink>
  );
}

export function PaginationNext({ className, ...props }: PaginationLinkProps) {
  return (
    <PaginationLink
      aria-label="Go to next page"
      size="sm"
      className={cn("gap-1 px-2.5", className)}
      {...props}
    >
      <span className="hidden sm:inline">Next</span>
      <svg
        viewBox="0 0 24 24"
        fill="none"
        aria-hidden="true"
        className={cn("size-4", mirrorForDirection)}
      >
        <path
          d="m9 18 6-6-6-6"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </PaginationLink>
  );
}

/**
 * Stands in for a run of skipped pages.
 *
 * Hidden from assistive technology and given screen-reader text, because "…"
 * is announced as nothing useful and the gap is already implied by the page
 * numbers either side.
 */
export function PaginationEllipsis({ className, ...props }: ComponentPropsWithRef<"span">) {
  return (
    <span
      data-slot="pagination-ellipsis"
      aria-hidden="true"
      className={cn("grid size-9 place-items-center text-muted-foreground", className)}
      {...props}
    >
      <svg viewBox="0 0 24 24" fill="none" className="size-4">
        <circle cx="5" cy="12" r="1.5" fill="currentColor" />
        <circle cx="12" cy="12" r="1.5" fill="currentColor" />
        <circle cx="19" cy="12" r="1.5" fill="currentColor" />
      </svg>
      <span className="sr-only">More pages</span>
    </span>
  );
}
ui/pagination-indicator.tsx
"use client";

// Motion from SmoothUI Pagination (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { useCallback, useLayoutEffect, useRef, useState, type CSSProperties } from "react";

import { cn } from "@/lib/utils";

/*
 * The client half of `<PaginationContent indicator="slide">`, in its own file
 * so the rest of Pagination stays renderable on the server.
 *
 * It measures its own parent list rather than being handed a ref, so
 * PaginationContent needs no state of its own. The move is a CSS transition
 * on transform and width with the overshoot curve standing in for SmoothUI's
 * spring; the global reduced-motion rule turns it into a jump.
 */

type Box = Pick<CSSProperties, "width" | "height" | "transform">;

const ACTIVE = '[data-slot="pagination-link"][aria-current="page"]';

function measure(list: HTMLElement): Box | null {
  const active = list.querySelector<HTMLElement>(ACTIVE);
  if (!active) return null;
  let { offsetLeft: x, offsetTop: y, offsetWidth: width, offsetHeight: height } = active;
  if (active.offsetParent !== list) {
    const from = list.getBoundingClientRect();
    const to = active.getBoundingClientRect();
    x = to.left - from.left - list.clientLeft;
    y = to.top - from.top - list.clientTop;
    width = to.width;
    height = to.height;
  }
  return { width, height, transform: `translate(${String(x)}px, ${String(y)}px)` };
}

function same(a: Box | null, b: Box | null) {
  return a?.transform === b?.transform && a?.width === b?.width && a?.height === b?.height;
}

/**
 * The sliding active-page pill. A list item because it lives in a `<ul>`,
 * and hidden from assistive technology so the list's item count is still the
 * number of pages: `aria-current` is what announces the current one.
 */
export function PaginationIndicator({ className }: { className?: string }) {
  const [box, setBox] = useState<Box | null>(null);
  const node = useRef<HTMLLIElement | null>(null);

  const update = useCallback(() => {
    const list = node.current?.parentElement;
    if (!list) return;
    const next = measure(list);
    setBox((previous) => (same(previous, next) ? previous : next));
  }, []);

  useLayoutEffect(() => {
    const list = node.current?.parentElement;
    if (!list) return;
    update();
    // Client-state pagination re-renders the links with a new aria-current;
    // a page list that grows or shrinks around the ellipsis changes sizes.
    const mutations = new MutationObserver(update);
    mutations.observe(list, {
      attributes: true,
      attributeFilter: ["aria-current"],
      childList: true,
      subtree: true,
    });
    const resizes = new ResizeObserver(update);
    resizes.observe(list);
    return () => {
      mutations.disconnect();
      resizes.disconnect();
    };
  }, [update]);

  return (
    <li
      ref={node}
      aria-hidden="true"
      data-slot="pagination-indicator"
      data-ready={box ? "" : undefined}
      className={cn(
        "pointer-events-none absolute rounded-md border border-border-strong bg-background",
        "transition-[transform,width,height] duration-[var(--duration-normal)] ease-[var(--ease-overshoot)]",
        "motion-reduce:transition-none",
        !box && "hidden",
        className,
      )}
      // Physical anchor, because the offsets it moves by are physical.
      style={box ? { top: 0, left: 0, ...box } : undefined}
    />
  );
}