Pagination
stimeo--pagination
Page navigation that says which page you are on and stops the buttons at the ends.
Handles paging through a list. Which page you are on is announced. On the first and last page the button with nowhere to go stops being pressable, and focus moves to the opposite button before that happens, so the keyboard never loses its place. Each change is reported, which is where you swap the list or update the URL. Rendering the page buttons, and eliding the middle of a long run, stay with your markup.
Page 1 of 5
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Activate a page / prev / next button. |
| Tab | Move between controls in the natural tab order (no roving). |
<%# Markup for the pagination demo.
Built on nav + aria-label and aria-current="page", the library handles current-page
management, boundary control of the prev/next buttons, and firing the change event.
Data fetching and URL rewriting are the consumer's (here demo.js just updates the
results area). %>
<nav
class="pagination"
data-controller="stimeo--pagination"
aria-label="<%= t("components.pagination.demo.label") %>"
data-stimeo--pagination-page-value="1"
data-stimeo--pagination-total-value="5">
<button
type="button"
class="pagination__button"
data-stimeo--pagination-target="prev"
data-action="click->stimeo--pagination#prev">
<%= t("components.pagination.demo.prev") %>
</button>
<% (1..5).each do |page| %>
<button
type="button"
class="pagination__button pagination__page"
data-page="<%= page %>"
<%= "aria-current=\"page\"".html_safe if page == 1 %>
data-stimeo--pagination-target="page"
data-action="click->stimeo--pagination#select"><%= page %></button>
<% end %>
<button
type="button"
class="pagination__button"
data-stimeo--pagination-target="next"
data-action="click->stimeo--pagination#next">
<%= t("components.pagination.demo.next") %>
</button>
</nav>
<p
class="pagination__status"
data-pagination-status
data-template="<%= t("components.pagination.demo.status_template") %>"
aria-live="polite">
<%= t("components.pagination.demo.initial_status", page: 1, total: 5) %>
</p>
/*
* Presentation-only styles for the pagination demo.
* The library toggles the current-page highlight via aria-current="page" and the
* boundary disabling via the disabled attribute. The consumer's CSS just reacts to
* those state hooks.
*/
.pagination {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.pagination__button {
min-width: 2.25rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
color: var(--fg, var(--color-text));
font: inherit;
cursor: pointer;
}
.pagination__button:hover:not(:disabled):not([aria-current="page"]) {
background: var(--surface-subtle);
}
.pagination__button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.pagination__button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pagination__page[aria-current="page"] {
border-color: var(--accent, var(--color-primary));
background: var(--accent-fill, var(--color-primary));
color: var(--white);
font-weight: 600;
}
.pagination__status {
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
// pagination event subscription demo (consumer-side JS).
//
// The core controller (stimeo--pagination) only handles current-page state,
// aria-current sync, disabling prev/next at the boundaries, and firing its events.
// Actually swapping the list or updating the URL is the consumer's job. Here we
// update the "Page X of Y" results area from both events: `change` is a
// navigation the reader made, and `reconcile` is the page moving the current page
// itself (a new `page`, or a `total` that pulls it down), so the status follows the
// page on screen whichever side moved it.
document.querySelectorAll('[data-controller~="stimeo--pagination"]').forEach((nav) => {
const status = nav.parentElement?.querySelector('[data-pagination-status]');
if (!status) return;
const template = status.dataset.template || 'Page {page} of {total}';
const showPage = (event) => {
const { page, total } = event.detail;
status.textContent = template
.replace('{page}', String(page))
.replace('{total}', String(total));
};
nav.addEventListener('stimeo--pagination:change', showPage);
nav.addEventListener('stimeo--pagination:reconcile', showPage);
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--pagination"
Targets
| Name | Description | Attribute |
|---|---|---|
page
|
A page button carrying data-page; the current one gets aria-current="page". |
data-stimeo--pagination-target="page" |
prev
|
The previous-page button; disabled at page 1. | data-stimeo--pagination-target="prev" |
next
|
The next-page button; disabled at the total (last page). | data-stimeo--pagination-target="next" |
Values
| Name | Description | Attribute |
|---|---|---|
page
|
The current page number (1-based). It is shown clamped to [1, total]; the value itself is left as written. Default 1. | data-stimeo--pagination-page-value |
total
|
The total number of pages. It is shown as a whole number of at least 1; the value itself is left as written. Default 1. | data-stimeo--pagination-total-value |
Actions
| Name | Description | Action |
|---|---|---|
next
|
Steps to the next page (clamped to total). | stimeo--pagination#next |
prev
|
Steps to the previous page (clamped to 1). | stimeo--pagination#prev |
select
|
Reads the clicked button's data-page and makes that page current. |
stimeo--pagination#select |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched when a navigation (select / prev / next) changes the current page; detail carries { page, total, previous, reason }. detail.reason reports user / focus / pointer / api. |
stimeo--pagination:change |
reconcile
|
Dispatched once per batch when a change on the page side — a new page or total — moves the current page shown; { page, total, previous } without reason, with previous the page shown before. Not fired on connect. |
stimeo--pagination:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-current |
Page button | "page" on the current page button only. |
disabled |
Prev / Next | Prev is disabled at page 1, Next at the last page. |