Accessibility

Pagination is a list of focusable items, not a row of buttons or links, and it has no aria-current. The sections below cover what to add, then naming, announcing and contrast.

What it renders

The root is a <ul> and every part of the control is an <li> inside it. A page number is an <a rel="nofollow"> with no href, wrapped in an <li tabindex="0"> that carries the click handler and the tab stop. Most of the advice below follows from that.
  • They are not links. With no href there is no middle-click, no open in a new tab, no visited state and nothing in a screen reader's list of links. If your pages have URLs, itemRender is where a real <a href> (or your router's Link) goes, and it is the biggest accessibility improvement you can make to this component.
  • They are not buttons either. The <li> carries no role, so assistive technology reads the number and nothing that says it is operable.
  • Enter is the only key that activates one. Space, which every real button honors, does nothing here.
  • Prev and next are real <button type="button"> elements, but with tabIndex={-1}. The tab stop is the <li> around them, and the button is there for styling.
  • Every aria-* and data-* prop you pass, and role, lands on the root <ul>. Wrapping the control in a <nav aria-label="Pagination"> is still clearer, because a name on the <ul> is a name on a list.

Marking the current page

The current page is marked by its styling only. Pagination sets no aria-current, so a screen reader reads the row as “1 2 3 4 5” with no sign of where the reader is.
  • Add it through itemRender: when type is page, clone the element with aria-current={page === current ? "page" : undefined}. It is one line, it is the thing this control most needs, and it is the same hook a real href goes through.
  • showTitle (default true) puts a title on each <li>: the page number on a page item, and the locale's strings on the arrows and jumpers. A title is a tooltip that some assistive technology falls back to as a name. It is not a reliable name, and it never reaches touch.
  • Turning showTitle off removes the only text the arrows and jumpers have. If you turn it off, put the names back through itemRender in the same change.

Naming prev, next and the jumpers

Prev, next and the jumpers have no accessible name of their own. The kit supplies their icons, and an item with an icon gets no aria-label.
  • What is left is the <li>'s title, which comes from locale.prev_page and locale.next_page (“Previous Page” and “Next Page” in English). Translate through locale rather than hardcoding a string into an itemRender.
  • Put a real name on the controls with itemRender: it is called for prev, next, jump-prev and jump-next as well as page, so one function can add an aria-label to each and return element untouched for the rest.
  • Name the jumpers by what they do. They move 5 pages, or 3 under showLessItems, which is what locale.prev_5 and locale.prev_3 say. “More” is not that.
  • The icons are decoration. The kit draws all four from your icon pack and mirrors them under dir="rtl". An icon you pass yourself neither mirrors nor gains a name.
  • Passing null for one of the four icon props draws no icon. A nulled arrow leaves an empty button, so give that item text or an aria-label instead.

Announcing the change, and focus

Changing the page replaces the list without moving focus and without saying anything. The reader who pressed Enter on “4” is still on “4”, and nothing has told them the content underneath is different.
  • Announce it yourself. A polite live region beside the list, written from the same onChange that refetches, is enough: “Showing 31 to 40 of 240 orders”.
  • showTotal renders that sentence already, but as an ordinary <li> inside the control. It is not a live region and announces nothing on its own.
  • Decide where focus goes and do it every time. Leaving it on the item that was pressed is the default and is fine. Moving it to the list's heading is better when the list is long. Doing one sometimes and the other the rest of the time is not.
  • Do not disable the control while the page loads. disabled takes every item out of the tab order, so the reader loses the control they were operating and has to find it again. Put the loading state on the list.
  • Disabled prev and next carry aria-disabled and stay in the DOM, so the start and end of the list are announced.

The jumper, the changer and simple

  • The quick jumper's input is named with aria-label={locale.page}, which is “Page” in English. The visible “Go to” and “Page” text around it is not associated with the input, so that one word is the whole accessible name.
  • It is type="text", so there is no numeric keypad on touch and no spin buttons. A non-numeric entry is discarded and an out-of-range one is clamped, both silently: nothing to announce as an error, and nothing to announce as a success either.
  • The size changer is a Select, so it has the combobox roles, the listbox and the keyboard behavior of that component. It is named with aria-label={locale.page_size}, “Page Size”.
  • Its options read as “20 / page”, which is locale.items_per_page appended to the number. A translation that drops the slash changes what is read out, so translate that string rather than restyling the option.
  • simple renders the current page as an editable <input type="text"> named from locale.jump_to, and Up and Down step it. Up goes back a page and Down goes forward, the opposite of what an arrow usually implies. simple={{ readOnly: true }} removes the input and that keyboard path with it.

Contrast and motion

  • The current page is one bordered item painted from colorPrimary. A color and a border work for most sighted readers and tell a screen reader nothing. Add aria-current for that.
  • Check the active item's text against colorPrimary as well as against the page. A primary color chosen for its hue can pass on the background and fail inside the item.
  • A jumper shows an ellipsis at rest, painted from colorTextDisabled, and swaps it for the double chevron on hover. It reads as disabled while it is the control that moves 5 pages at a time, so do not lean on it as the only way to skip ahead: keep the quick jumper, or the first and last page items.
  • That swap is an opacity transition over motionDurationMid, and it is hover-only, so it never fires for a keyboard reader. Anything you add on top of it belongs behind motion-safe:.
  • The transitions here are short and can be turned off with theme={{ token: { motion: false } }} on a ThemeScope, which is what a reader who asked for reduced motion should get.