Persisting Selection Across Pages and Filters
Permalink to "Persisting Selection Across Pages and Filters"In a paginated or filtered grid, users often select rows across several pages: tick five invoices on page one, three on page two, filter to another customer and tick two more. If selection is stored by row index or in DOM checkboxes, it vanishes on every page change — or, worse, silently attaches to different rows. If it persists without saying so, a user may run “Archive selected” believing it affects the three visible rows when it affects ten. Screen reader users are most exposed to both failures, because they cannot see checkbox states on other pages.
This guide belongs to bulk selection & batch actions.
Spec reference
Permalink to "Spec reference"- SC 4.1.2 Name, Role, Value (A) — each row checkbox exposes its checked state; the select-all checkbox exposes checked, unchecked or mixed for its scope.
- SC 4.1.3 Status Messages (AA) — selection counts are status messages, especially when hidden rows are included.
- SC 3.3.4 Error Prevention (AA) — for actions with legal, financial or data consequences, users must be able to review and confirm; stating the full scope of a bulk action is part of that.
- SC 1.3.1 Info and Relationships (A) — the selection summary should be associated with the grid and the bulk toolbar.
When to use — and when not to
Permalink to "When to use — and when not to"Persist selection when users genuinely build selections across pages or filters — reconciliation, bulk tagging, exports. Make persistence visible: a summary bar that is always shown while anything is selected.
Clear selection automatically only when users would expect it — after a bulk action completes, or when navigating to a different dataset entirely — and announce it. The misapplication: persisting selection silently and offering a bulk action labelled only “Delete” with no count.
Annotated code example
Permalink to "Annotated code example"const selected = new Set(); // record IDs, not indices (SC 4.1.2 via rendered checkboxes)
function renderPage(rows) {
for (const row of rows) checkboxFor(row.id).checked = selected.has(row.id);
const onPage = rows.filter(r => selected.has(r.id)).length;
headerCheckbox.checked = onPage === rows.length && onPage > 0;
headerCheckbox.indeterminate = onPage > 0 && onPage < rows.length;
updateSummary(onPage);
}
function updateSummary(onPage) {
const total = selected.size, elsewhere = total - onPage;
summary.textContent = total ? `${total} selected: ${onPage} on this page, ${elsewhere} on other pages` : '';
bulkArchive.textContent = `Archive ${total} selected invoices…`; // scope in the name (SC 3.3.4)
bulkArchive.hidden = total === 0;
}
function toggle(id, on) {
on ? selected.add(id) : selected.delete(id);
renderPage(currentRows);
status.textContent = `${selected.size} selected`; // SC 4.1.3
}
selectAllMatching.addEventListener('click', async () => {
const ids = await api.idsMatching(currentFilters); // explicit, counted
ids.forEach(id => selected.add(id));
renderPage(currentRows);
status.textContent = `All ${ids.length} matching invoices selected`;
});
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Space on a row checkbox | “Select INV-1042, checked” then “4 selected” | — |
| Page 2 with selections elsewhere | Header checkbox: “Select all on this page, checkbox, not checked” | Mixed announced as “partially checked” |
| Tab to summary | “12 selected: 3 on this page, 9 on other pages” | Summary text read |
| Activate bulk action | Confirmation dialog: “Archive 12 invoices?” | — |
| Select all matching | “All 240 matching invoices selected” | — |
Integration context
Permalink to "Integration context"Select-all scope and indeterminate state are covered in implementing select all with indeterminate checkbox state, and announcements in announcing selection count changes in data grids. The bulk toolbar follows contextual bulk action toolbars. Search state persistence uses the same identity principle — see persisting search state across pages and sorts.
Gotchas
Permalink to "Gotchas"Filtered-out selections. When a filter hides selected rows, keep them selected but say so: “9 selected rows are hidden by the current filter”.
Deleted records. If a selected record is deleted elsewhere, remove it from the selection and update the count.
Server pagination. “Select all on this page” and “select all matching” are different scopes; never conflate them.
Worked example
Permalink to "Worked example"A finance team bulk-approves expenses across pages. Their grid stored selection in DOM checkboxes, so paging lost it, and a “Select all” header checkbox silently meant “all 2,400 matching” in one view and “this page” in another. After moving selection to an ID set, adding a persistent summary (“14 selected: 4 on this page, 10 on other pages”), naming bulk buttons with counts and adding an explicit “Select all 2,400 matching” link, mis-approvals stopped and a screen reader user could build and review selections confidently.
Common mistakes
Permalink to "Common mistakes"- Selection stored by index or DOM state.
- Header checkbox scope that changes between views.
- Bulk buttons without counts.
- No review of hidden selected rows.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Make selection a grid-level store keyed by rowId, with built-in summary and scope-aware bulk toolbar. Product teams should not reimplement cross-page selection.
Step-by-step implementation
Permalink to "Step-by-step implementation"- Store selection by stable ID. Keep a
Setof record IDs rather than row indexes, so selections survive sorting, paging and filtering. Row indexes change the moment the view changes; IDs do not. - Derive checkbox state from the set. When a page renders, each row checkbox reads
selected.has(row.id). The header checkbox becomesmixedwhen some but not all visible rows are selected, and its accessible name should say what it covers: “Select all 25 rows on this page”. - Render a persistent selection summary. Outside the table, show “12 selected across 3 pages” with a “Show selected” and a “Clear selection” control. This summary is the only place where off-screen selections become visible, so it must never scroll away with the rows.
- Announce changes politely. After a toggle, update a polite live region with the new total. Do not announce on page change unless the count changed.
- Reconcile on filter. When a filter hides selected rows, keep them selected but say so: “12 selected, 4 hidden by the current filter”. Batch actions should state exactly which rows they will affect before running.
Edge cases
Permalink to "Edge cases"- Deleted records. If a selected record is deleted by another user, drop it from the set on the next fetch and update the summary; do not let a batch action fail silently on a missing ID.
- Select all matching. “Select all 2,431 matching rows” is a different state from a set of IDs; represent it as a query plus exclusions, and say so in the summary.
- Session expiry. Decide whether selection survives reloads. If it does, restore the summary on load so users are not surprised by a pre-filled batch.
FAQ
Permalink to "FAQ"Should selection persist across pages by default?
For grids used for bulk work, yes, with a visible summary. For simple lists, clearing on page change can be clearer — be consistent.
What should the header checkbox select?
Rows on the current page. Offer “select all matching” separately.
How do users review selections on other pages?
A “Review selection” view or filter that shows only selected rows.
Should selection survive navigation away and back?
If the task spans visits, store it in the URL or session; otherwise clear it and say so on return.
How large can a selection be?
As large as the backend supports; for “all matching”, operate on the filter rather than shipping thousands of IDs.
How do I announce hidden selections?
Include them in the summary text and in bulk action names and confirmations.
Related
Permalink to "Related"- Select all with indeterminate state — header checkbox scope
- Announcing selection counts — the messages
- Error summaries for batch failures — after the bulk action