Monet
@etched/monet 0.0.1

Pagination

Ported

Moves 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.

Kumo source
26–50 of 284
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

1–25 of 284
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

126–150 of 284
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

276–284 of 284
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

1–18 of 18
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

0 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.

1–25 of 1,284
Per page
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.

51–75 of 2,840
26–50 of 140
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.

126–150 of 284
26–50 of 120
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.

1–25 of 284
1–25 of 284
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

26–50 of 140
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.

51–75 of 284
26–50 of 120
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 Enter and Space, and Tab skips the disabled ends. In the page field, Enter goes to the typed page and does not submit a form.
  • A navigation landmark named by aria-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 with aria-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 gets aria-disabled.
  • Give each pagination on a screen its own aria-label, such as "Runs pages".

API reference

PropTypeDefaultDescription
page *number–The current page, from 1.
perPagenumber25Items on each page.
totalCountnumber–Items on all pages. When the API does not give a total, leave it out and set hasNextPage.
hasNextPagebooleanfalseWhether 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.
perPageOptionsreadonly 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.
infoReactNode–Replaces the "26–50 of 284" text. null hides it.
disabledboolean–Disables every control, for example while a page loads. A current page number stays on, so it can keep focus.
aria-labelstring"Pagination"Name of the navigation landmark. Give each one on a screen its own name.
firstPageLabelstring"First page"Names the first page button. Pass translated text, as for the other labels.
previousPageLabelstring"Previous page"Names the previous page button.
nextPageLabelstring"Next page"Names the next page button.
lastPageLabelstring"Last page"Names the last page button.
pageLabelstring"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").
ofLabelstring"of"The word between a page and the last page, and in the items text: "3 of 12".
perPageLabelstring"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.