Highlighting Search Matches with the mark Element
Permalink to "Highlighting Search Matches with the mark Element"The mark element represents text highlighted for reference because of its relevance in the current context — which is precisely what a search match inside a table is. Using it instead of a styled span gives you a semantic hook, a default style that maps to system colours, and a pattern that keeps the cell’s text intact for assistive technology. Done badly, match highlighting splits words into fragments that screen readers read as separate chunks, or produces a yellow background that vanishes in Windows contrast themes.
This guide is part of table search and match highlighting.
Spec reference
Permalink to "Spec reference"The HTML Living Standard defines mark as phrasing content with no implicit ARIA role in most mappings; Chrome and Firefox expose it as mark (ARIA 1.3 adds a mark role), and screen reader support for announcing it is uneven. That is acceptable: the highlight is a visual aid, and the match count announcement carries the information for screen reader users.
Relevant criteria:
- SC 1.4.1 Use of Color (A) — if the only difference between a match and its neighbours is a background colour, add a second cue (underline, outline or bold).
- SC 1.4.3 Contrast (AA) — text inside the highlight must still reach 4.5:1 against the highlight colour.
- SC 1.4.11 Non-text Contrast (AA) — the current-match indicator, if it is a border or outline, needs 3:1 against the cell background.
- SC 1.3.1 Info and Relationships (A) — the cell’s text content must not change; only its structure.
When to use — and when not to
Permalink to "When to use — and when not to"Use mark for matches that stay in place: in-grid find, highlighting the query inside search results, or emphasising a filtered term inside long text cells. It is designed for “this is relevant right now”.
Do not use mark for permanent emphasis such as a status (“Overdue”) or for errors in a cell — those are not search relevance and belong in text, icons with names, or aria-invalid. Do not use it on content that is itself interactive in a way that the split would break; wrapping part of a link’s text in mark is fine, but rebuilding a button’s label every keystroke can make some screen readers re-announce the button.
The misapplication to name explicitly is replacing cell content with innerHTML built from the query. That destroys event listeners, loses focus if the focused cell is rebuilt, and opens an injection hole when the query contains markup.
Annotated code example
Permalink to "Annotated code example"// Highlight every match of `query` inside grid cells without touching their text content
function highlight(gridEl, query) {
clearHighlights(gridEl);
if (!query) return [];
const needle = query.toLocaleLowerCase();
const marks = [];
const walker = document.createTreeWalker(gridEl.tBodies[0], NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
const hay = node.data.toLocaleLowerCase();
let at = hay.indexOf(needle);
let current = node;
while (at !== -1) {
const match = current.splitText(at); // text before stays in `current`
const rest = match.splitText(needle.length); // text after the match
const mark = document.createElement('mark'); // semantic highlight (HTML: mark)
mark.className = 'grid-match';
match.replaceWith(mark);
mark.append(match); // same characters: text content unchanged (SC 1.3.1)
marks.push(mark);
current = rest;
at = current.data.toLocaleLowerCase().indexOf(needle);
}
}
return marks;
}
function clearHighlights(gridEl) {
for (const m of gridEl.querySelectorAll('mark.grid-match')) {
const parent = m.parentNode;
m.replaceWith(...m.childNodes);
parent.normalize(); // re-merge split text nodes
}
}
/* Two cues: colour and underline (SC 1.4.1); text contrast checked against #fff4c2 (SC 1.4.3) */
mark.grid-match { background: #fff4c2; color: inherit; text-decoration: underline 2px; }
/* Current match: outline at 3:1 against the cell (SC 1.4.11) */
mark.grid-match[data-current] { outline: 2px solid #155c38; outline-offset: 1px; }
/* Windows contrast themes: map to system highlight colours */
@media (forced-colors: active) {
mark.grid-match { background: Highlight; color: HighlightText; forced-color-adjust: none; }
mark.grid-match[data-current] { outline-color: CanvasText; }
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Situation | Expected result | AT deviations |
|---|---|---|
| Arrowing onto a cell containing a match | Cell text read in full, as before highlighting | Firefox + NVDA may say “highlighted” before the matched fragment when the mark role is exposed |
| Reading the row in browse mode | Words not split; text read continuously | Older JAWS versions pause briefly at element boundaries |
| VoiceOver with rotor | No new rotor items added | — |
| Windows High Contrast | Matches visible in system Highlight colour | Without the forced-colors rule the yellow disappears entirely |
Integration context
Permalink to "Integration context"Highlighting is purely visual, so it must be paired with the count message from announcing match counts for in-grid search and with a way to move between matches, covered in find next and previous in data grids. The colour choices follow the same rules as chart colour and contrast: two cues, both themes, forced colours handled explicitly.
Gotchas
Permalink to "Gotchas"Matches across element boundaries. “Lund & Co” may be split across a text node and an abbreviation element. The tree-walker approach will not find matches that span nodes; either accept that, or search the cell’s textContent and map offsets back to nodes.
Virtualized grids. Recycled rows are rebuilt as you scroll, so highlights vanish. Re-run the highlighter for the rows rendered in each frame, from a match list computed against the data, not the DOM.
Highlighting inside the focused editor. Never split text inside an input or a contenteditable cell that is in edit mode; it moves the caret. Skip cells that are being edited.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Expose two tokens — match background and current-match outline — with light, dark and forced-colors values, and document the contrast each was verified against. The highlighter itself belongs in the grid package, not in product code, because it must cooperate with virtualization and edit mode; a product team calling innerHTML on cells is the failure this guide exists to prevent.
FAQ
Permalink to "FAQ"Do screen readers announce mark elements?
Some do, inconsistently. NVDA with Firefox can say “highlighted”; JAWS and VoiceOver usually say nothing. Treat the highlight as visual and announce the count separately.
Can I use the CSS Custom Highlight API instead of mark?
Yes, and it avoids DOM changes entirely: create Range objects for each match and register them with CSS.highlights. Check forced-colours behaviour, because ::highlight() styles need their own forced-colors rule, and support in older browsers is limited.
Should the highlight colour come from design tokens?
Yes. Define a light and dark highlight token and verify 4.5:1 text contrast against each, plus a forced-colours fallback. A hard-coded yellow is the most common dark-mode failure for search highlights.
Related
Permalink to "Related"- Announcing match counts — the spoken half of search
- Find next and previous — moving to the current match
- Forced colours mode for SVG charts — system colours in practice