Pagination
PortedMoves through the pages of a long list. Shows which items are on screen and the current page. Native attributes and ref go to the root; aria-label names the navigation inside it.
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function Preview() {
const [page, setPage] = useState(2);
return <Pagination aria-label="Runs pages" page={page} onPageChange={setPage} controls="simple" totalCount={284} />;
}Installation
import { Pagination } from "@etched/monet/pagination";Examples
First page
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function FirstPage() {
const [page, setPage] = useState(1);
return <Pagination aria-label="Runs pages" page={page} onPageChange={setPage} totalCount={284} />;
}Middle page
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function MiddlePage() {
const [page, setPage] = useState(6);
return <Pagination aria-label="Lots pages" page={page} onPageChange={setPage} totalCount={284} />;
}Last page
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function LastPage() {
const [page, setPage] = useState(12);
return <Pagination aria-label="Benches pages" page={page} onPageChange={setPage} totalCount={284} />;
}Single page
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function SinglePage() {
const [page, setPage] = useState(1);
return <Pagination aria-label="Sites pages" page={page} onPageChange={setPage} totalCount={18} />;
}No results
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function NoResults() {
const [page, setPage] = useState(1);
return <Pagination aria-label="Alerts pages" page={page} onPageChange={setPage} totalCount={0} />;
}Page size
When the page size changes, go back to page 1.
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function PageSize() {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
return (
<Pagination
aria-label="Chips pages"
page={page}
onPageChange={setPage}
perPage={perPage}
totalCount={1284}
onPerPageChange={(size) => {
setPerPage(size);
setPage(1);
}}
/>
);
}Go to a page
pageSelector="input" takes a typed page number; "dropdown" lists every page, for short lists.
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function GoToPage() {
const [page, setPage] = useState(3);
const [shortPage, setShortPage] = useState(2);
return (
<div className="rows">
<Pagination aria-label="Candidates pages" page={page} onPageChange={setPage} totalCount={2840} pageSelector="input" />
<Pagination aria-label="Roles pages" page={shortPage} onPageChange={setShortPage} totalCount={140} pageSelector="dropdown" />
</div>
);
}Page numbers
controls="numbers" shows the first and last pages and the pages around the current one, with an ellipsis for each gap. Up to seven pages, it shows them all.
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function PageNumbers() {
const [page, setPage] = useState(6);
const [shortPage, setShortPage] = useState(2);
return (
<div className="rows">
<Pagination aria-label="Wafers pages" page={page} onPageChange={setPage} controls="numbers" totalCount={284} />
<Pagination aria-label="Builds pages" page={shortPage} onPageChange={setShortPage} controls="numbers" totalCount={120} />
</div>
);
}Pages in the URL
With link, each control is a link to its page (here <a>; a router link works too), so pages open in new tabs and the Back button works. The app reads the page from the URL.
import { useEffect, useState } from "react";
import { Pagination } from "@etched/monet/pagination";
/** The page in the URL hash, such as `#jobs-page=3`. An app reads its router's search params instead. */
function useHashPage(key: string) {
const [page, setPage] = useState(1);
useEffect(() => {
const read = () => setPage(Number(new URLSearchParams(location.hash.slice(1)).get(key)) || 1);
read();
window.addEventListener("hashchange", read);
return () => window.removeEventListener("hashchange", read);
}, [key]);
return page;
}
export function PagesInTheUrl() {
const jobs = useHashPage("jobs-page");
const tickets = useHashPage("tickets-page");
return (
<div className="rows">
<Pagination aria-label="Jobs pages" page={jobs} totalCount={284} link={(page) => <a href={`#jobs-page=${page}`} />} />
<Pagination aria-label="Tickets pages" page={tickets} controls="numbers" totalCount={284} link={(page) => <a href={`#tickets-page=${page}`} />} />
</div>
);
}Simple controls
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function SimpleControls() {
const [page, setPage] = useState(2);
return <Pagination aria-label="Owners pages" page={page} onPageChange={setPage} controls="simple" totalCount={140} />;
}Unknown total
When the API gives no total, set hasNextPage. The controls are then always simple.
import { useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function UnknownTotal() {
const [page, setPage] = useState(1);
return <Pagination aria-label="Events pages" page={page} onPageChange={setPage} hasNextPage={page < 3} />;
}Disabled while loading
disabled turns the controls off while a page loads. With page numbers, the current page stays on, so focus stays on it. Here each page takes a second to load.
import { useEffect, useState } from "react";
import { Pagination } from "@etched/monet/pagination";
export function DisabledWhileLoading() {
const [page, setPage] = useState(3);
const [scan, setScan] = useState(2);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!loading) return;
const timer = setTimeout(() => setLoading(false), 1000);
return () => clearTimeout(timer);
}, [loading]);
const load = (next: number) => {
setScan(next);
setLoading(true);
};
return (
<div className="rows">
<Pagination aria-label="Probes pages" page={page} onPageChange={setPage} totalCount={284} disabled />
<Pagination aria-label="Scans pages" page={scan} onPageChange={load} controls="numbers" totalCount={120} disabled={loading} />
</div>
);
}Accessibility
- Buttons take
EnterandSpace, andTabskips the disabled ends. In the page field,Entergoes to the typed page and does not submit a form. - A
navigationlandmark named byaria-label(default "Pagination"). Buttons are named "First page", "Previous page" and so on, and "Page 6" with page numbers. - The current page has
aria-current="page". The range, such as "26–50 of 284", is a polite live region witharia-atomic, so it is read whole on each change. - When the focused button turns off, at an end or while loading, focus moves to the current page. With
link, an off link getsaria-disabled. - Give each pagination on a screen its own
aria-label, such as "Runs pages".
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| page * | number | – | The current page, from 1. |
| perPage | number | 25 | Items on each page. |
| totalCount | number | – | Items on all pages. When the API does not give a total, leave it out and set hasNextPage. |
| hasNextPage | boolean | false | Whether a next page exists. Used only without totalCount. |
| controls | "full" | "simple" | "numbers" | "full" | Without totalCount, controls are always "simple". |
| onPerPageChange | (perPage: number) => void | – | Shows a page size select and is called with the new size. Usually also go back to page 1. |
| perPageOptions | readonly number[] | [25, 50, 100, 250] | Sizes in the page size select. |
| pageSelector | "input" | "dropdown" | – | Lets users go straight to a page, in "full" controls: "input" takes a typed page number (Enter or leaving the field goes there), "dropdown" lists every page (keep it for short lists). Without it, the current page is text. |
| info | ReactNode | – | Replaces the "26–50 of 284" text. null hides it. |
| disabled | boolean | – | Disables every control, for example while a page loads. A current page number stays on, so it can keep focus. |
| aria-label | string | "Pagination" | Name of the navigation landmark. Give each one on a screen its own name. |
| firstPageLabel | string | "First page" | Names the first page button. Pass translated text, as for the other labels. |
| previousPageLabel | string | "Previous page" | Names the previous page button. |
| nextPageLabel | string | "Next page" | Names the next page button. |
| lastPageLabel | string | "Last page" | Names the last page button. |
| pageLabel | string | "Page" | The word for a page: it names the page field, and starts the current page's text and each page number's name ("Page 3"). |
| ofLabel | string | "of" | The word between a page and the last page, and in the items text: "3 of 12". |
| perPageLabel | string | "Per page" | The page size select's text and name. |
| onPageChange | (page: number) => void | – | Called with the page to show. Required without link. |
| link | (page: number) => ReactElement<{ className?: string }> | – | Pages in the URL: each control is a link to its page, such as link={(page) => <a href={?page=${page}} />} or a router link. At an end, the link is off (aria-disabled) and its click does nothing. |