Pagination
stimeo--pagination
Page navigation with current-page state, aria-current sync, and boundary disabling.
The stimeo--pagination controller manages page navigation on a navigation landmark. It holds the current page and total, syncs aria-current="page" onto the active page button (removing it from the rest), and disables the prev/next buttons at the boundaries — moving focus off a button before disabling it so focus is never stranded. Each change dispatches stimeo--pagination:change so the consumer can swap the list or update the URL. Generating and eliding the page buttons stays with your markup. Behavior only — the look is yours.
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) {
border-color: var(--accent, var(--color-primary));
}
.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
required
|
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. |