Pagination gotchas in LWC
A pager looks like three buttons, but most of the work is in edge cases: the data shrinking under you, screen readers, and the site theme.
While adding pagination to a shopping cart, we compared our new pager (newPaginator) with the one already in the codebase (oldPaginator). This post walks through six gotchas, with the old and new code side by side, and why we're proposing the new approach as the shared one.
Try it: shrink the last page
Both pagers start with 41 items at 20 per page, on page 3, which holds one item. Remove it and compare.
At a glance
Each row is a section below, with code from both.
| Gotcha | oldPaginator | newPaginator |
|---|---|---|
| 1. Data shrinks under the current page | Keeps its own page number, so it can point past the last page | Page is derived and clamped on every render |
| 2. Equality checks and off-by-one guards | === and <= checks that break once the page is out of range | Range checks plus one clamp in goToPage() |
| 3. Accessibility | Arrow buttons are labeled; page changes aren't announced | Current page marked with aria-current; range line announced via aria-live |
| 4. Coupling to a data source | Fires a Commerce search action unless standalone is set | Only changes its own page; the parent owns the data |
| 5. Theme surprises | Hand-rolled round buttons with their own tokens | SLDS outline buttons themed with the site's --brand-* tokens |
| 6. Where the user lands after paging | Stays scrolled at the bottom | Scrolls back to the top of the list, respecting reduced motion |
Gotcha 1: The page outlives the data
The page number has to be re-checked every time the list changes, not only when someone clicks. In a cart, items disappear while you're looking at them: you remove one, or change a quantity and the list reloads.
Take a cart with 41 items at 20 per page. You're on page 3, which holds a single item, and you remove it. There are now 2 pages, but a pager that stores its own page number still says page 3.
set currentPage(value) {
this._currentPage = parseFloat(value);
}
handleNextPage(event) {
event.preventDefault();
event.stopPropagation();
if (this._currentPage <= this.totalPages) {
this._currentPage++;
this.dispatchUpdateCurrentPageEvent();
}
}get totalPages() {
return Math.max(1, Math.ceil(this.totalItems / this._pageSize));
}
// Clamped so removing the last item on the last page falls back a page.
get page() {
return Math.min(this.currentPage, this.totalPages);
}
get visibleItems() {
return this.items?.slice(this.pageStart, this.pageStart + this._pageSize);
}The old pager only changes its private copy on clicks, so nothing re-checks it when totalPages drops. The new one stores what the user asked for (currentPage) and derives what it shows (page).
The rule: store intent, derive state. A getter can't drift out of date, because it runs again whenever its inputs change.
Gotcha 2: Equality checks and off-by-one guards
An === check on a page number is only safe if the number can never leave the valid range. Once it can (Gotcha 1), the checks that disable the buttons stop working.
get disablePrevious() {
return this._currentPage === 1;
}
get disableNext() {
return this._currentPage === this.totalPages;
}handlePreviousPage() {
this.goToPage(this.page - 1);
}
handleNextPage() {
this.goToPage(this.page + 1);
}
handlePageClick(event) {
this.goToPage(parseInt(event.currentTarget.dataset.page, 10));
}
goToPage(page) {
const target = Math.min(Math.max(page, 1), this.totalPages);
if (target === this.page) return;
this.currentPage = target;
// ...scroll handling, see Gotcha 6
}With the page stuck at 3 of 2, 3 === 2 is false, so Next stays enabled and goes to page 4. The new pager sends every way of changing page through one clamp.
The old click handler is also one off: this._currentPage <= this.totalPages lets the page go one past the end. Today only the disabled button stops that, so the two bugs are hiding each other.
isLastPage in the new pager still uses ===, but on the clamped page, where equality is safe. The page size setting gets the same care: it's parsed with parseInt and falls back to 20 when it's blank or not positive, so a bad value can't produce NaN pages.
Gotcha 3: Accessibility is more than labeled buttons
A sighted user sees the list change when they page. A screen reader user hears nothing unless the pager says so. The old pager gets the basics right (its icon-only arrows have aria-labels), but it leaves out three things:
- Page changes are silent. Nothing announces that page 2 is now showing.
- The current page isn't marked, except as the value of a dropdown.
- Disabled arrows are invisible but still there.
opacity: 0hides them from sighted users, but screen readers still list "Previous Page, unavailable", so the two audiences get different pages.
<nav aria-label="Page" class="pagination">
<button type="button" aria-label={labelAriaPrev}
disabled={disablePrevious} onclick={handlePreviousPage}>
<c-icon icon-name="angle-left"></c-icon>
</button>
<div class="page-numbers">
<div>Page</div>
<lightning-combobox label="Page" variant="label-hidden"
value={pageValue} options={pageOptions}
onchange={handleGotoPage}></lightning-combobox>
<div>{numberOfItems}</div>
</div>
<!-- next button mirrors previous -->
</nav><nav lwc:if={showPagination} class="pagination"
aria-label="Cart pages">
<p class="page-range" aria-live="polite">{pageRangeLabel}</p>
<div class="page-controls">
<button type="button" class="slds-button slds-button_outline-brand"
disabled={isFirstPage} onclick={handlePreviousPage}>Previous</button>
<template for:each={pageButtons} for:item="button">
<span lwc:if={button.isGap} key={button.key}
class="page-gap">…</span>
<button lwc:else key={button.key} type="button"
class={button.className} aria-label={button.label}
aria-current={button.ariaCurrent} data-page={button.number}
onclick={handlePageClick}>{button.number}</button>
</template>
<button type="button" class="slds-button slds-button_outline-brand"
disabled={isLastPage} onclick={handleNextPage}>Next</button>
</div>
</nav>button {
/* ... */
&[disabled] {
opacity: 0;
}
}// The line the live region reads out
get pageRangeLabel() {
const first = this.pageStart + 1;
const last = Math.min(this.pageStart + this._pageSize, this.totalItems);
const range = first === last ? first : `${first}–${last}`;
return `Showing ${range} of ${this.totalItems} items`;
}The new pager adds a live region, marks the current page, keeps disabled buttons visible, and gives the nav a specific name ("Page" is vague once a page has more than one nav).
After a click, a screen reader says "Showing 21–30 of 30 items". The current button reads "Page 2, current page". Disabled buttons stay visible and greyed out, so what you see and what you hear match.
Gotcha 4: A pager that knows where the data comes from
A pager should report "the user wants page 3" and stop there. The old pager was built for search results, so by default it changes the Commerce search itself.
async dispatchUpdateCurrentPageEvent() {
if (this.standalone) {
this.dispatchEvent(
new CustomEvent("pagechange", { detail: { page: this._currentPage } })
);
this.dispatchEvent(new CustomEvent("busy", { detail: false }));
return;
}
this.dispatchEvent(new CustomEvent("busy", { detail: true }));
await dispatchActionAsync(
this,
createSearchFiltersUpdateAction({ page: this._currentPage })
);
this.dispatchEvent(new CustomEvent("busy", { detail: false }));
}// parent.html
// <c-new-paginator
// total-items={totalItems}
// page-size={pageSize}
// current-page={page}
// onpagechange={handlePageChange}
// ></c-new-paginator>
// parent.js: the parent owns the data
handlePageChange(event) {
this.currentPage = event.detail.page;
}
// A search page would dispatch its own search action here instead.The proposed shared contract: numbers in, one event out. Slicing, reloading and the busy spinner stay with the component that owns the data.
The old design causes two problems:
- Forgetting the flag fails quietly. Drop
standaloneon a cart or wishlist and the arrows send a search-filter action that nothing on that page is listening for. - It invites forks. A second copy of the old pager already exists to remove this coupling, so there are now two near-identical paginators to maintain.
Gotcha 5: SLDS class names don't mean what they say on our site
This one caught our own first draft. The storefront loads a theme stylesheet (theme.css) that changes what the SLDS button classes look like:
| Class | What SLDS docs show | What our site shows |
|---|---|---|
slds-button_neutral | White button, grey border | Filled --brand-blue-100, white text (our primary action) |
slds-button_outline-brand | White button, brand-blue border | White, --brand-gray-50 border, --brand-gray-05 hover (our secondary style) |
// Classes picked from the SLDS docs
className: isCurrent
? "slds-button slds-button_brand page-number"
: "slds-button slds-button_neutral page-number"
// On our site: a row of filled navy buttons,
// each styled like a primary action..page-controls {
--slds-c-button-outline-brand-color-border: var(--brand-gray-25);
--slds-c-button-outline-brand-color-border-hover: var(--brand-gray-100);
--slds-c-button-text-color: var(--brand-text-color);
--slds-c-button-text-color-hover: var(--brand-head-color);
/* plus the matching --sds-c-* aliases, as theme.css does */
font-weight: 500;
}
.page-number[aria-current="page"] {
--slds-c-button-outline-brand-color-background: var(--brand-blue-10);
--slds-c-button-outline-brand-color-border: var(--brand-black);
--slds-c-button-text-color: var(--brand-head-color);
}The old pager avoids the problem by drawing its own round buttons with its own tokens, which means it re-implements hover, focus and disabled states. The new one uses the outline class the theme already supports and only adjusts tokens. The current page borrows the theme's "selected" checkbox-pill look.
Two things to take away. Check the theme stylesheet before picking an SLDS variant, not the SLDS docs. And style the current page with the aria-current attribute instead of a separate class, so the visual state can't disagree with the accessible one.
Gotcha 6: Where the user lands after a page change
The pager sits under the list, so a user who clicks Next is looking at the bottom of the new page. They have to scroll back up to find its first item. The old pager leaves the scroll position alone. The new one scrolls the list back into view, but only if its top is off screen.
const list = this.template.querySelector(".list");
if (list?.getBoundingClientRect().top < 0) {
list.scrollIntoView({ behavior: "smooth", block: "start" });
}const list = this.template.querySelector(".list");
if (list?.getBoundingClientRect().top < 0) {
const reduceMotion = globalThis.matchMedia?.(
"(prefers-reduced-motion: reduce)"
).matches;
list.scrollIntoView({
behavior: reduceMotion ? "auto" : "smooth",
block: "start"
});
}behavior: "smooth" in JavaScript ignores the user's reduced-motion setting, and site CSS can't override it, so the code has to ask matchMedia itself.
The top < 0 check matters on short pages: without it, the page jumps even when the whole list is already visible.
Neither pager moves keyboard focus yet. Focus stays on the button you pressed, which works for repeated Next presses, and the live region from Gotcha 3 announces the new range. If we hear that people lose their place, the next step is to move focus to the first item of the new page.
Where the dropdown still wins
The old pager's "Page [▾] of N" design has real strengths, and a shared pager should keep the option:
- Same width at any page count. Numbered buttons get wider as pages grow. The new pager limits this by collapsing to
1 … 4 [5] 6 … 12, but a dropdown is still narrower on a phone. - Reaches any page. With 50 pages, a dropdown gets to page 37 in two clicks. Numbered buttons make you step there.
- It already matches the search results page, so changing it means changing a pattern users already know.
A cart rarely has more than a few pages, so numbered buttons fit it well. Search results can run to dozens of pages, where the dropdown makes sense. The fixes for Gotchas 1–4 and 6 apply to both designs.
Checklist for any pager
- The page is derived and clamped, not stored and incremented
- Every way of changing page goes through one clamping function
- The pager emits an event; the parent owns the data and the busy state
- Page changes are announced (
aria-live) and the current page hasaria-current="page" - Disabled controls look disabled instead of disappearing
- Button styles are checked against the theme stylesheet, not the SLDS docs
- After a page change the top of the list is in view, and reduced motion is respected
- Tested with the last page shrinking (remove its only item) and with one page (no pager)