Table Search & Match Highlighting
Permalink to "Table Search & Match Highlighting"Search inside a table looks like a small feature — a text box and some yellow highlights — but it touches almost every part of an accessible grid. It changes what is visible without moving focus, so it needs a status message. It changes cell markup, so it must not damage the text screen readers read. It promises “there are matches here”, so it must provide a keyboard route to each one. And it has state that sorting, paging and the Back button all try to destroy.
The users most affected are screen reader users, who cannot see highlights at all and depend on counts and focus movement; screen magnifier users, who see only a small part of the grid and cannot spot a highlight two hundred rows away; keyboard-only users, who need every match to be reachable without a mouse; and users of Windows contrast themes, for whom an unconsidered yellow background simply disappears. The failure this topic prevents is a search box that works for a sighted mouse user and is silent, invisible or unreachable for everyone else.
This topic belongs to accessible data tables & grid systems.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"| Criterion | Level | Relevance to in-grid search |
|---|---|---|
| 1.3.1 Info and Relationships | A | Highlighting must not alter cell text or table structure |
| 1.4.1 Use of Color | A | Matches need a non-colour cue such as underline or outline |
| 1.4.3 Contrast (Minimum) | AA | Highlighted text still reaches 4.5:1 |
| 1.4.11 Non-text Contrast | AA | The current-match outline reaches 3:1 |
| 2.1.1 Keyboard | A | Every match reachable with find next and previous |
| 2.4.3 Focus Order | A | Jumping to a match moves grid focus, and navigation continues from there |
| 2.4.6 Headings and Labels | AA | The field says whether it filters or finds |
| 4.1.3 Status Messages | AA | Match counts and positions announced without moving focus |
Prerequisites
Permalink to "Prerequisites"- A grid with a working focus model — roving tabindex or aria-activedescendant — because find next must use it.
- Familiarity with ARIA live regions for dynamic data, especially why a region must exist before its content changes.
- An understanding of how sortable and filterable grids announce changes, since search state has to survive them.
- For large datasets, the rendering model from accessible virtualized list patterns, because off-screen rows complicate both highlighting and focus.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"| Element / attribute | Valid values | When to apply | Common misuse |
|---|---|---|---|
input type="search" |
— | The query field; gives a clear button in some browsers | Using a placeholder instead of a label |
label |
Text naming the behaviour | Always; “Find in table” or “Filter rows” | “Search” for a field that actually filters |
role="status" |
— (implicit polite, atomic) | Count and position messages | Inserting it at the moment of the first message |
aria-describedby |
ID of the status element | So refocusing the field reads the last count | Pointing at a hidden element that is never updated |
mark |
— | Each match fragment | Wrapping whole cells or using it for permanent emphasis |
aria-rowcount |
Integer | Grids that render a subset; update after filtering | Leaving the unfiltered total after rows are removed |
aria-keyshortcuts |
e.g. F3 Shift+F3 |
On the “Next match” button | Declaring shortcuts that are not implemented |
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — Choose search or filter (SC 2.4.6)
Permalink to "Step 1 — Choose search or filter (SC 2.4.6)"Decide what the box does before writing any code. If users act on the matching subset, it is a filter; if they inspect matches in context, it is a find. The comparison in searching versus filtering a data grid covers the trade-off. Label the field with the matching verb.
<label for="find">Find in table</label> <!-- verb names the behaviour (SC 2.4.6) -->
<input id="find" type="search" aria-describedby="find-status" aria-keyshortcuts="Enter Shift+Enter">
Step 2 — Render the status region on load (SC 4.1.3)
Permalink to "Step 2 — Render the status region on load (SC 4.1.3)"The region must exist, empty, before the first query. Screen readers watch existing live regions for changes; they do not reliably announce a region that appears with text already in it.
<p id="find-status" role="status" class="find-status"></p> <!-- present from first paint -->
Step 3 — Highlight without changing text (SC 1.3.1, 1.4.1)
Permalink to "Step 3 — Highlight without changing text (SC 1.3.1, 1.4.1)"Split text nodes and wrap each match in mark, as shown in highlighting search matches with the mark element. Give the highlight a second cue and a forced-colours rule.
mark.grid-match { background: var(--match-bg); text-decoration: underline 2px; } /* SC 1.4.1 */
@media (forced-colors: active) { mark.grid-match { background: Highlight; color: HighlightText; } }
Step 4 — Announce one count per query (SC 4.1.3)
Permalink to "Step 4 — Announce one count per query (SC 4.1.3)"Debounce for about half a second and write one complete sentence. The details and wording are in announcing match counts for in-grid search.
input.addEventListener('input', debounce(() => {
const { rows, cells } = runSearch(input.value.trim());
status.textContent = cells ? `${cells} matches in ${rows} rows` : 'No matches. Check spelling or clear the search.';
}, 500));
Step 5 — Move focus between matches (SC 2.1.1, 2.4.3)
Permalink to "Step 5 — Move focus between matches (SC 2.1.1, 2.4.3)"Enter and Shift+Enter in the field, plus F3 inside the grid, walk the ordered match list and move grid focus with the same API the arrow keys use. See find next and previous in data grids.
function goTo(i) {
const m = matches[i];
grid.focusCell(m.row, m.col); // real focus (SC 2.4.3)
setTimeout(() => { status.textContent = `Match ${i + 1} of ${matches.length}`; }, 80);
}
Step 6 — Persist state (SC 3.3.7)
Permalink to "Step 6 — Persist state (SC 3.3.7)"Store the query and the identity of the current match — row ID and column, not an index — in the URL, and re-anchor after every sort, filter or page change. Persisting search state across pages and sorts has the full routine.
history.replaceState(null, '', `?q=${encodeURIComponent(q)}&m=${row}:${col}`);
Keyboard interaction contract
Permalink to "Keyboard interaction contract"| Key | Action | Expected AT announcement | Failure indicator |
|---|---|---|---|
| Typing in the field | Runs the search after a pause | “14 matches in 9 rows” | A count per keystroke, or silence |
| Enter | Focus next match | Cell content with column header, then “Match 2 of 14” | Focus stays in the field |
| Shift+Enter | Focus previous match | As above | Moves forward |
| F3 / Shift+F3 in the grid | Next / previous without returning to the field | As above | Browser’s find bar opens instead |
| Escape in the grid | Return to the field | Field label and current query | Focus lost to the page body |
| Escape in the field | Clear query | “Search cleared” or silence | Highlights remain with an empty field |
| Ctrl+F / Cmd+F | Browser find (untouched) | Browser find bar | Custom find intercepts it |
Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"| AT + browser | Count after typing | Jump to match | Notes |
|---|---|---|---|
| NVDA + Firefox | Spoken after echo | Cell and header read | May say “highlighted” on mark |
| NVDA + Chrome | Spoken after echo | Cell and header read | Reading cursor follows focus in focus mode |
| JAWS + Chrome | Spoken; may wait for echo to finish | Cell read; status sometimes cut | Delay the status ~80 ms after focus |
| VoiceOver + Safari | Spoken | Cell read with header | Use pageshow for restored state |
| TalkBack + Chrome | Spoken after keyboard settles | Cell read, header sometimes omitted | Test with the on-screen keyboard dismissed |
| Narrator + Edge | Spoken | Cell read | Scan mode must be off in the grid |
Edge cases & failure modes
Permalink to "Edge cases & failure modes"1. Highlights in virtualized rows disappear on scroll
Permalink to "1. Highlights in virtualized rows disappear on scroll"Diagnosis: recycled rows are re-rendered from data without marks. Fix: compute matches against the data model and re-apply highlights to rendered rows on every render; find next scrolls the target row into view before focusing it.
2. The count includes hidden columns
Permalink to "2. The count includes hidden columns"Diagnosis: users hear “14 matches” but can only reach 11 because three are in a hidden column. Fix: count only visible columns, and recompute when the column chooser changes.
3. Focus lost when the focused cell is highlighted
Permalink to "3. Focus lost when the focused cell is highlighted"Diagnosis: the highlighter rebuilds the focused cell with innerHTML. Fix: split text nodes in place; never replace focused elements.
4. Ctrl+F hijacked
Permalink to "4. Ctrl+F hijacked"Diagnosis: a custom find replaced browser find, so magnifier users lost their usual tool. Fix: leave Ctrl+F alone; offer custom find on Enter, F3 or Ctrl+G and document it.
5. Stale count after sort
Permalink to "5. Stale count after sort"Diagnosis: the status still says “Match 7 of 12” for a different row. Fix: store the current match by identity and re-anchor after sorting.
Announcing match counts
Permalink to "Announcing match counts"The count is the only part of search that reaches a screen reader user without them moving. It must be a single sentence, written once typing pauses, in a status region that already exists. Include the query — “14 matches in 9 rows for ‘overdue’” — because after a long pause users may not remember what they typed. The full pattern, including zero-match wording and AT differences, is in announcing match counts for in-grid search.
status.textContent = `${cells} matches in ${rows} rows for “${q}”`; // one sentence, polite
Highlighting matches with mark
Permalink to "Highlighting matches with mark"mark is the semantic element for “relevant to what you are doing now”. Wrapping only the matching characters, by splitting text nodes, keeps the cell’s text and accessible name identical. Two visual cues and a forced-colours rule make the highlight survive colour-vision differences and contrast themes. See highlighting search matches with the mark element.
const match = textNode.splitText(at); match.splitText(q.length);
const mark = document.createElement('mark'); match.replaceWith(mark); mark.append(match);
Find next and previous
Permalink to "Find next and previous"A highlight that cannot be reached is decoration. Find next moves real grid focus to the matched cell, so the user arrives where arrow keys and table reading commands work, and then hears its position. Leave the browser’s Ctrl+F alone. See find next and previous in data grids.
grid.focusCell(m.row, m.col); // the same call the arrow keys use
Searching versus filtering
Permalink to "Searching versus filtering"A filter removes rows and a search highlights them; screen reader users can only tell which happened from the label and the status message. Label the control with its verb and announce the right thing — a row count for a filter, a match count for a search. See searching versus filtering a data grid.
<label for="f">Filter rows</label> <!-- not "Search" -->
Persisting search state
Permalink to "Persisting search state"Sorting, paging and navigation all threaten search state. Keep the query and the current match’s identity in the URL, recompute matches after every change, and announce restoration once. See persisting search state across pages and sorts.
const i = matches.findIndex(m => m.rowId === current.rowId && m.col === current.col);
Testing checklist
Permalink to "Testing checklist"Automated
Permalink to "Automated"Keyboard
Permalink to "Keyboard"Screen reader
Permalink to "Screen reader"Design system integration
Permalink to "Design system integration"In-grid search is easiest to get right once, centrally, and hardest to retrofit product by product. A design system that ships it should provide:
- Tokens for the match background, the current-match outline and their dark and forced-colours equivalents, each documented with the contrast ratio it was verified against.
- A
GridFindcomponent that owns the field, the status region, the debounce and the find-next buttons, so a team cannot wire one without the others. - A
GridFiltercomponent with its own label and row-count status, kept separate from find so the two behaviours are never blurred. - Grid API hooks —
findNext(),findPrevious(),matchesand a render callback — so virtualized and paginated grids can re-apply highlights and move focus correctly. - Translatable message templates with plural rules for counts and positions.
Map each piece to its criterion in the component documentation — tokens to 1.4.1, 1.4.3 and 1.4.11, the status region to 4.1.3, find to 2.1.1 and 2.4.3 — so reviewers can see at a glance which guarantee each part provides.
FAQ
Permalink to "FAQ"Is browser Ctrl+F enough for accessible table search?
For a static, non-virtualized table, it is a reasonable baseline and it is accessible. It fails when rows are virtualized or paginated, because browser find only searches what is in the DOM; that is when a custom find is justified.
Should the search field live inside the grid's toolbar?
It can, if the toolbar follows the toolbar pattern and Tab moves out of it predictably. A field outside the grid, directly above it, is simpler and avoids arrow-key conflicts with the toolbar’s own navigation.
Do matches need to be announced as the user arrows through the grid?
No. The highlight is visual; announcing “match” on every cell would be noise. Users who want matches use find next, which announces the position.
How do I test search with a screen reader quickly?
Type a query and wait — you should hear one count. Press Enter — you should hear a cell and then a position. Sort — you should hear the sort, then the same match’s new position. If any of those three is silent, something is missing.
Related
Permalink to "Related"- Sortable & filterable data grids — the operations search must survive
- Accessible filtering & faceted search — when users want fewer rows, not highlights
- Pagination & result-set navigation — matches on other pages
- Empty, error & no-results states — when nothing matches
- Screen reader announcement strategies — keeping counts from colliding with other messages