Elements

Pagination

A pagination moves among the pages of a set: search results, the rows of a table, an archive. It shows Previous, the pages around the current one (the first and the last always, an ellipsis for each run left out), the app’s children and Next. It owns the behavior: which pages show, presses, the keyboard, Previous and Next disabled at the ends, the navigation landmark and the current page for screen readers. The look is the app’s: the items have no color, size or label of their own, and the app styles them or draws them.

el-pagination.tsx
el-pagination.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Pagination } from "nsh/base";
import { desktop, Log, Panel, Segmented, Split, ui } from "./palette";

// Search results paged with a count, and a compact pager beside them. The
// look is all `itemClass` over the component's own numbers and labels:
// quiet items until hovered, the current page lit in the brand color
// (`selected:`) with `transition-colors` (still with reduced motion), the
// ends dimmed (`disabled:`).
const itemClass =
  "min-w-8 h-8 px-2 items-center justify-center rounded-md text-sm text-foreground " +
  "transition-colors duration-200 motion-reduce:transition-none hover:bg-hover active:opacity-70 " +
  "selected:bg-brand selected:text-on-brand disabled:opacity-35 focus:outline-2 focus:outline-ring";

const total = 238;
const page = signal(1);
const perPage = signal<"10" | "25">("10");
const chapter = signal(1);
const events = signal<string[]>([]);

const pages = () => Math.ceil(total / Number(perPage()));
const first = () => (page() - 1) * Number(perPage()) + 1;
const last = () => Math.min(page() * Number(perPage()), total);

const changed = (next: number) => {
  page.set(next);
  events.set([`onChange({ page: ${next} })`, ...events()].slice(0, 4));
};

function App() {
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>pagination</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click a page, or Tab to one and press Return" : "Tap a page"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="page · totalPages">
              <Text style={{ fontSize: 13, color: ui().text }}>{`${page()} of ${pages()}`}</Text>
            </Panel>
            <Log title="onChange({ page })" lines={events()} minHeight={110} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 18, gap: 14 }}>
          <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
            <Text style={{ fontSize: 13, color: ui().muted }}>{`Showing ${first()}–${last()} of ${total} results`}</Text>
            <Segmented
              size="small"
              options={[
                { value: "10", label: "10" },
                { value: "25", label: "25" },
              ]}
              value={perPage()}
              onChange={(next) => {
                const row = first();
                perPage.set(next);
                page.set(Math.floor((row - 1) / Number(next)) + 1);
              }}
            />
          </View>
          <Pagination
            page={page()}
            totalPages={pages()}
            visiblePages={desktop ? 7 : 5}
            accessibilityLabel="Search results pages"
            previousAccessibilityLabel="Previous"
            nextAccessibilityLabel="Next"
            class="gap-1 flex-wrap"
            itemClass={itemClass}
            onChange={({ page: next }) => changed(next)}
          />
          <View style={{ height: 1, backgroundColor: ui().line }} />
          <Pagination
            compact
            page={chapter()}
            totalPages={12}
            accessibilityLabel="Chapters"
            previousAccessibilityLabel="Previous chapter"
            nextAccessibilityLabel="Next chapter"
            class="items-center justify-between"
            itemClass={`${itemClass} px-3 border border-border`}
            onChange={({ page: next }) => chapter.set(next)}
          >
            <Text style={{ fontSize: 13, color: ui().muted }}>{`Chapter ${chapter()} of 12`}</Text>
          </Pagination>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{pagination, theme};
use nsh_core::prelude::*;

fn results() -> impl IntoElement {
    let page = signal(1u32);
    pagination()
        .page(move || page.get())
        .total_pages(24)
        .accessibility_label("Search results")
        .previous_accessibility_label("Previous")
        .next_accessibility_label("Next")
        .item_class("min-w-9 h-9 px-2 items-center justify-center rounded-md hover:bg-accent selected:bg-primary selected:text-primary-foreground disabled:opacity-50")
        .gap_1()
        .on_change(move |p: &u32| page.set(*p))
}

Pagination comes from nsh/base, the nsh-base crate in Rust. In Rust, pagination() makes one with a setter per prop (.page(..), .total_pages(..)), .on_change(..) for the page the user picks, the typed style methods for the row and .child(..) for the app’s content.

#The page

The page is the app’s: page is the current one, from 1, and onChange({ page }) reports the page the user picked with a press on a page, on Previous or on Next. The app sets it, loads that page and shows its rows. totalPages is how many there are; a page past the last shows the last. The current page reports nothing when pressed, and neither do Previous on the first page and Next on the last.

#The pages shown

With visiblePages (at least 5, the default) the row shows every page while they fit. Past that it shows the first and the last page, the current one with as many pages on each side as fit, (visiblePages - 3) / 2, and an ellipsis for each run of pages left out:

page of 20, visiblePages={7}Shown
11 2 3 4 5 … 20
61 … 4 5 6 7 8 … 20
201 … 16 17 18 19 20

Ten thousand pages show the same seven items. An odd visiblePages centers the current page.

#Items

Each item takes the app’s itemClass and itemStyle: the current page is selected:, an item that takes no press disabled:, the item with the keyboard focus focus:. The row is the app’s to lay out with class (a row by default; gap-*, justify-*).

Without renderItem a page shows its number, an ellipsis …, and Previous and Next their accessibility labels (‹ and › without them). renderItem draws each item’s content from a PaginationItemState:

FieldTypeDescription
kind"previous" | "page" | "ellipsis" | "next"What the item is.
pagenumberA page’s number; the page Previous or Next goes to (the current one at the ends); an ellipsis’s first hidden page.
start, endnumberAn ellipsis’s hidden pages: start included, end (the next page shown) not.
currentbooleanThe current page.
disabledbooleanIt takes no press: Previous on the first page, Next on the last, everything of a disabled pagination.
<Pagination
  page={page()}
  totalPages={pages()}
  previousAccessibilityLabel="Previous page"
  nextAccessibilityLabel="Next page"
  itemClass="size-9 items-center justify-center rounded-md selected:bg-primary"
  renderItem={(item) =>
    item.kind === "previous" ? <Icon name="chevron-left" /> :
    item.kind === "next" ? <Icon name="chevron-right" /> :
    item.kind === "ellipsis" ? <JumpMenu from={item.start!} to={item.end!} onPick={(p) => page.set(p)} /> :
    <Text class={item.current ? "font-semibold text-primary-foreground" : "text-foreground"}>{item.page}</Text>
  }
  onChange={({ page: next }) => page.set(next)}
/>

An ellipsis takes no press of its own: what it does is the app’s. A Menu drawn in it lists the pages it hides, as JumpMenu above does.

#Compact

compact shows Previous, the children and Next only: a pager for a narrow screen, the page told in the children.

<Pagination compact page={page()} totalPages={12} onChange={({ page: next }) => page.set(next)} class="items-center gap-3">
  <Text class="text-sm text-muted-foreground">{`${page()} of 12`}</Text>
</Pagination>

The children sit after the pages and before Next in either form: a page count, a field to go to a page.

#Props

PropRustTypeDefaultDescription
page.page(..)numberrequiredThe current page, from 1; the app sets it in onChange.
totalPages.total_pages(..)numberrequiredHow many pages there are, at least 1.
visiblePages.visible_pages(..)number5The most pages shown at once, the first and the last included; at least 5.
compact.compact(..)booleanfalsePrevious, the children and Next only.
disabled.disabled(..)booleanfalseNo item takes a press or the keyboard focus; disabled: styles apply, and screen readers say they are dimmed.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers call the navigation: what it pages through.
previousAccessibilityLabel, nextAccessibilityLabel.previous_accessibility_label(..), .next_accessibility_label(..)stringnoneWhat screen readers say for Previous and Next, and their content without renderItem.
itemClass, itemStyle.item_class(..), .item_style(|s| ..)string, StyleProp<TextStyle>noneEach item’s look: selected: on the current page, disabled:, focus:; text styles pass to its content.
renderItem.render_item(|s: &PaginationItemState| ..)(item: PaginationItemState) => JSX.ElementnoneDraws each item’s content from its state.
children.child(..)JSX.ElementnoneThe app’s content after the pages, before Next.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe row’s look and layout.

#Events

EventRustDescription
onChange.on_change(|page: &u32| ..){ page }: the page the user picked with a page, Previous or Next. The current page is never reported.

#Motion

The current page moves from item to item as the app’s selected: classes say: transition-colors on itemClass fades the highlight from the page left to the page reached. A page change keeps every item in place and changes what the items show, so the row never flickers; the app’s transitions stand still with reduced motion (motion-reduce:transition-none).

#Accessibility

  • The row is a navigation landmark named by accessibilityLabel.
  • Each page, Previous and Next is a button. The current page is said as the current page (ARIA’s aria-current="page") and is selected; Previous on the first page and Next on the last are dimmed.
  • On macOS and the web each page, Previous and Next is a stop of the keyboard focus, and Return and Space press it. A page pressed from the keyboard keeps the focus once the app shows it, wherever it lands in the row, and so does the first or the last page when Previous or Next reaches it.

See Accessibility for labels, roles and announcements in general.

#Platform notes

  • iOS, Android and Mac Catalyst: a press is a tap that ends on an item.
  • Desktops (macOS, Windows, Linux, GPUI) and the web: a press is a click; on macOS and the web, Tab reaches the items.
  • Every platform draws what the app styles.