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.status_template", 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) {
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, 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 change-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 change.
// Actually swapping the list or updating the URL is the consumer's job. Here we
// subscribe to change and update the "Page X of Y" results area.
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}';
nav.addEventListener('stimeo--pagination:change', (event) => {
const { page, total } = event.detail;
status.textContent = template
.replace('%{page}', String(page))
.replace('%{total}', String(total));
});
});
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, clamped to [1, total]); default 1. | data-stimeo--pagination-page-value |
total
|
The total number of pages; 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 the current page changes; detail carries { page, total, previous }. |
stimeo--pagination:change |
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. |