Skip to content

Paginator

A page navigation component for splitting content across multiple pages.

Usage

The controls are authored - how many pages to show, and where to elide a long range, is the page's decision - and they are root-buttons, so a page control reads as the button it is. The paginator owns the state: it moves aria-current="page" and sets data-variant/data-color on every control, and marks a step aria-disabled when there is nowhere left to go.

<nav is="root-paginator" aria-label="Paginator" current="2" total="3">
  <button is="root-button" type="button" previous square aria-label="Previous page">
    <i is="root-icon" icon="caret-left"></i>
  </button>
  <button is="root-button" type="button" data-page="1">1</button>
  <button is="root-button" type="button" data-page="2">2</button>
  <button is="root-button" type="button" data-page="3">3</button>
  <button is="root-button" type="button" next square aria-label="Next page">
    <i is="root-icon" icon="caret-right"></i>
  </button>
</nav>

Clicking a page moves there; previous and next step one page and stop at the ends. data-page names the page a control stands for - a control marked page instead takes the number in its label, or its position in the row.

The steps carry a chevron - caret-left / caret-right, the same shape Select and TreeView point with. A step drawn as a glyph alone needs aria-label: the icon is decorative, so without one the control has no accessible name at all. A step that also says the word keeps both, and needs no label.

The four shapes

Two independent choices - numbered pages or not, and what sits beside the steps - which is four rows in practice. The variant and color of every control follow from the first of them and are written by the component; see below.

Chevron steps and numbered pages. The densest of the four: the row is the page numbers, and the steps are two glyphs beside them.

<nav is="root-paginator" aria-label="Paginator" current="2" total="3">
  <button is="root-button" type="button" previous square aria-label="Previous page">
    <i is="root-icon" icon="caret-left"></i>
  </button>
  <button is="root-button" type="button" data-page="1">1</button>
  <button is="root-button" type="button" data-page="2">2</button>
  <button is="root-button" type="button" data-page="3">3</button>
  <button is="root-button" type="button" next square aria-label="Next page">
    <i is="root-icon" icon="caret-right"></i>
  </button>
</nav>

Worded steps and a counter between them. No numbers to click, so the steps say what they do and the counter says where you are - which is the whole of the state in a row of three things.

<nav is="root-paginator" aria-label="Paginator" current="2" total="5">
  <button is="root-button" type="button" previous>
    <i is="root-icon" icon="caret-left"></i>Previous
  </button>
  <span data-part="counter" aria-live="polite">Page 2 of 5</span>
  <button is="root-button" type="button" next>
    Next<i is="root-icon" icon="caret-right"></i>
  </button>
</nav>

A page-size choice, chevron steps and numbered pages. The select leads the row and the controls stay together at the end of it.

<nav is="root-paginator" aria-label="Paginator" current="2" total="5">
  <select is="root-select" data-part="per-page" aria-label="Items per page">
    <option value="10">10 / page</option>
    <option value="20" selected>20 / page</option>
    <option value="50">50 / page</option>
    <option value="100">100 / page</option>
  </select>
  <button is="root-button" type="button" previous square aria-label="Previous page">
    <i is="root-icon" icon="caret-left"></i>
  </button>
  <button is="root-button" type="button" data-page="1">1</button>
  <button is="root-button" type="button" data-page="2">2</button>
  <button is="root-button" type="button" data-page="3">3</button>
  <button is="root-button" type="button" next square aria-label="Next page">
    <i is="root-icon" icon="caret-right"></i>
  </button>
</nav>

A page-size choice, worded steps and a counter. The fullest of the four, and the one a long table wants: how much is shown, where you are in it, and one step either way.

<nav is="root-paginator" aria-label="Paginator" current="2" total="5">
  <select is="root-select" data-part="per-page" aria-label="Items per page">
    <option value="10">10 / page</option>
    <option value="20" selected>20 / page</option>
    <option value="50">50 / page</option>
    <option value="100">100 / page</option>
  </select>
  <button is="root-button" type="button" previous>
    <i is="root-icon" icon="caret-left"></i>Previous
  </button>
  <button is="root-button" type="button" next>
    Next<i is="root-icon" icon="caret-right"></i>
  </button>
  <span data-part="counter" aria-live="polite">Page 2 of 5</span>
</nav>

Variant and color

A page control does not name its own data-variant/data-color: the paginator writes both, and which pair depends on what the row contains.

Numbered - at least one [data-page]/[page] control - every control, steps included, is quiet: tertiary/neutral. The page you are on is the one exception, filled with primary/default, which is what makes it read as the current page in a row that otherwise says nothing about itself.

Step-only - no page controls, just the two steps - has nothing else in the row to carry emphasis, so the steps take it themselves: secondary/default.

Items per page

An optional <select is="root-select" data-part="per-page">, authored like the page controls: the paginator does not decide which sizes to offer, only reads which one is picked. A change dispatches per-page-change and nothing else - the host re-renders its own rows, which is what produces the new total and the new set of [data-page] controls.

It is a root-select rather than a bare <select> for the reason the page controls are root-buttons: a native picker beside a row of root controls is the one thing in the composition the design system is not painting. The field's 20ch floor is dropped here

  • that is the width a form field wants, and roughly three times what "20 / page" needs next to a row of digits.

Placed by where it sits in the markup, not by an attribute: written before the page controls it sits on the left of the row, written after them it sits on the right. An auto margin on the select is what does it, so the controls themselves stay together in the middle instead of being spread across the row.

Page counter

An optional [data-part="counter"]: the paginator keeps its text current - Page { current} of { lastPage} - on every navigation, so it is the one part of this composition that is not purely authored. Meaningful with or without the select beside it; the two are independent, and where each lands is again DOM order.

Sizes

data-size takes small and large; medium is the default and needs no value written. The step moves the row's gap, the floor a page control is drawn at, and the text with them - so a paginator under a dense table can be smaller than one closing a page.

<nav is="root-paginator" aria-label="Small" data-size="small" current="2" total="3">
  <button is="root-button" type="button" previous square aria-label="Previous page">
    <i is="root-icon" icon="caret-left"></i>
  </button>
  <button is="root-button" type="button" data-page="1">1</button>
  <button is="root-button" type="button" data-page="2">2</button>
  <button is="root-button" type="button" data-page="3">3</button>
  <button is="root-button" type="button" next square aria-label="Next page">
    <i is="root-icon" icon="caret-right"></i>
  </button>
</nav>
<nav is="root-paginator" aria-label="Large" data-size="large" current="2" total="3">
  <button is="root-button" type="button" previous square aria-label="Previous page">
    <i is="root-icon" icon="caret-left"></i>
  </button>
  <button is="root-button" type="button" data-page="1">1</button>
  <button is="root-button" type="button" data-page="2">2</button>
  <button is="root-button" type="button" data-page="3">3</button>
  <button is="root-button" type="button" next square aria-label="Next page">
    <i is="root-icon" icon="caret-right"></i>
  </button>
</nav>

Attributes

Attribute Type Default Description
current number 1 The page being shown; written by the component as it navigates
total number last control The last page there is, which is what bounds next
data-size 'small' | 'large' medium Row gap, control floor and text step
aria-label string 'Paginator' Accessible label

Parts

Part Element Description
per-page <select is="root-select"> Optional. The page-size choice; authored, read-only to the component
counter any Optional. "Page X of Y", kept in sync by the component

Events

Event Detail Description
root-paginator.page-change { page } Fired after the paginator moves to a new page. A click that does not move - a page that is already current, a step at the end of the range - emits nothing
root-paginator.per-page-change { perPage } Fired when [data-part="per-page"] changes. The paginator does not act on it - re-rendering the rows, the total and the [data-page] controls is the host's

Extends

HTMLElement - use with <nav is="root-paginator">. Read currentPage for the page being shown and lastPage for the end of the range; goTo(page) navigates, and is what the controls call.