Pagination
Navigation between pages of a list.
Installation
pnpm dlx @dowel-ui/cli add paginationInstalls 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
| Prop | Type | Default |
|---|---|---|
indicator
| "none" | "slide" | "none" |
Plus every attribute of <ul>.
PaginationItem
Plus every attribute of <li>.
PaginationLink
| Prop | Type | Default |
|---|---|---|
isActiveMarks the page the user is on. Sets aria-current="page". | boolean | — |
size | "sm" | "md" | "icon" | "icon-sm" | "icon" |
asChild | boolean | — |
Plus every attribute of <a>.
PaginationPrevious
| Prop | Type | Default |
|---|---|---|
isActiveMarks the page the user is on. Sets aria-current="page". | boolean | — |
size | "sm" | "md" | "icon" | "icon-sm" | — |
asChild | boolean | — |
Plus every attribute of <a>.
PaginationNext
| Prop | Type | Default |
|---|---|---|
isActiveMarks the page the user is on. Sets aria-current="page". | boolean | — |
size | "sm" | "md" | "icon" | "icon-sm" | — |
asChild | boolean | — |
Plus every attribute of <a>.
PaginationEllipsis
Plus every attribute of <span>.
Quality
7/7 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — does not apply
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Source
This is exactly what dowel add pagination writes into your project, with imports rewritten to your own path alias.
// 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>
);
}
"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}
/>
);
}