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.
What changes and what must not when you add marks Three layers: the visible highlight changes, the DOM structure changes with new mark elements, and the cell's text content and accessible name stay exactly the same. What changes and what must not when you add marksVisible highlightbackground plus underline on each match; outline on the current oneDOM structuretext node split into text, mark, text — new element boundariesText contentunchanged: "Overdue since 3 March" before and afterAccessible nameunchanged for cells, row headers and links that contain matches
Only presentation and node structure change; the text a screen reader reads stays identical.

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.

innerHTML rebuild versus text-node splitting The left column lists the problems with rebuilding cell innerHTML from the query; the right lists the properties of splitting existing text nodes with the DOM API. innerHTML rebuild versus text-node splitting✗ Rebuild with innerHTMLFocus lost if the focused cell is rebuiltQuery with < or & injects markupListeners on cell children are destroyedWhole cell re-announced by some readers✓ Split text nodesFocused element is untouchedQuery is never parsed as HTMLChildren keep their listenersOnly text structure changes

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
A row before and after highlighting Grid mock with one header row and two data rows. The cell "Overdue since March" contains a highlighted match and is the current match with a focus outline; another row has a second, non-current match. A row before and after highlightingInvoiceCustomerStatusAmountINV-1042Lund & CoOverdue since March1,240.00INV-1043Mesa FoodsOverdue 12 days310.50INV-1044Arden LtdPaid88.00
The current match carries an outline; other matches carry only the background and underline.

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.

Permalink to "Related"

← Back to Table Search & Match Highlighting