Tech · · 6 min read
Highlighting text without touching the DOM: the Custom Highlight API
Style arbitrary text ranges with CSS and no wrapper elements. The short list of properties that apply, live versus static ranges, and the overlap rules.
To highlight search matches, the usual approach wraps each match in a <mark>. That mutates the DOM, invalidates whatever framework owns that subtree, and forces layout. The Custom Highlight API does it with CSS instead:
::highlight(search) {
background-color: #fde68a;
color: #111;
}const highlight = new Highlight();
CSS.highlights.set("search", highlight);
highlight.add(someRange);Nothing is inserted, removed or re-parented. It became Baseline newly available on 24 March 2026, when Firefox 149 shipped the ::highlight() pseudo-element.
This covers the three objects involved, the short list of properties that actually apply, and the behaviour of overlapping and stale ranges.
The three pieces
Highlight is a set of Range or StaticRange objects. It has add, delete, has, clear and size, and it is iterable.
CSS.highlights is the registry — a map from a name to a Highlight. A highlight that is not in the registry paints nothing.
::highlight(name) is the pseudo-element that styles it. The name argument is required, and it is a <custom-ident>, so no quotes.
A working search highlighter, end to end:
const highlight = new Highlight();
CSS.highlights.set("search", highlight);
function showMatches(root, query) {
highlight.clear();
if (!query) return;
const needle = query.toLowerCase();
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const haystack = node.textContent.toLowerCase();
let i = haystack.indexOf(needle);
while (i !== -1) {
const range = new Range();
range.setStart(node, i);
range.setEnd(node, i + needle.length);
highlight.add(range);
i = haystack.indexOf(needle, i + needle.length);
}
}
}Register the Highlight once and mutate it. The spec requires the browser to repaint when ranges are added to or removed from a registered highlight, and that repaint is asynchronous — the API does not block waiting for it.
One caution on the matching itself, unrelated to the API: toLowerCase() can change a string's length for some characters, which puts your offsets out by one. For anything beyond ASCII search, match on the original string with a locale-aware comparison rather than lowercasing both sides.
Only a short list of properties applies
Highlight pseudo-elements accept a deliberately small set of properties, all of them things that cannot affect layout. From the spec:
colorbackground-colortext-decorationand its associated properties, includingtext-underline-positionandtext-underline-offsettext-shadowstroke-color,fill-colorandstroke-width- custom properties
Everything else is ignored. No padding, no border-radius, no font-weight, no transform. If your highlight design needs a rounded pill, you still need real elements.
The spec adds a warning worth heeding: "Historically (and at the time of writing) only color and background-color have been interoperably supported." Treat the decoration and stroke properties as progressive enhancement and make sure the highlight reads correctly with colour alone.
For properties that are not on the list but are needed to resolve the ones that are — font-size for em units, line-height for lh, color-scheme for system colours, custom properties for var() — the computed values are copied from the originating element. Vendor-prefixed properties such as -webkit-text-fill-color do not apply.
There are no default styles. A registered but unstyled custom highlight must not change the page's appearance at all, so forgetting the CSS produces silence, not a default yellow.
Live ranges or static ranges
You can build a highlight from either, and they style identically. The difference is what happens when the document changes.
Range | StaticRange | |
|---|---|---|
| Boundary points after a DOM change | Adjusted by the browser, and repainted | Left alone; may now point at the wrong text |
| Cost | The browser tracks every range against every DOM mutation | Nothing to track |
| Use for | Editable content, collaborative selections, anything long-lived | One-shot highlights over content you control, recomputed on change |
The rule of thumb: if the text under the highlight can be edited while the highlight exists, use Range. If you rebuild the highlights yourself whenever the content changes — the search box case above — StaticRange avoids making the browser do tracking work you are already doing.
Overlaps, priority and stacking
Three rules decide what you see when highlights collide.
Within one highlight, overlapping ranges render as their union. Two ranges with a semi-transparent background produce one wash, not a darker band where they cross. Collapsed ranges are not rendered at all.
Between highlights, the priority property decides the stacking order — higher is on top, the default is 0, and ties go to whichever was registered into the registry most recently. The spec's worked example: two highlights with no priority set, foo (blue text on yellow) and bar (orange background only), overlapping. The overlap takes bar's orange background because it was registered last, and still shows foo's blue text, because bar sets no colour of its own.
const current = new Highlight(currentMatchRange);
current.priority = 1; // paints above the other matches
CSS.highlights.set("current", current);Against the browser's own highlights, custom highlights lose. They sit below the built-in highlight pseudo-elements in the stacking order, so a user's ::selection always paints over your highlight rather than under it. That is the right default — it means selecting text stays legible — but it does mean you cannot use a custom highlight to override selection styling.
Tell assistive technology what the highlight means
Each Highlight has a type, defaulting to highlight. The spec says user agents "should make custom highlights available to assistive technologies" and should map the type to the most specific platform accessibility concept available.
const misspellings = new Highlight();
misspellings.type = "spelling-error";Set spelling-error when you are marking misspelled text and grammar-error for grammatical problems. For everything else, the spec's advice is to leave it as highlight — the initial set of types was chosen because platform accessibility APIs can already express them, and there is no benefit to overloading them.
That is the honest limit of the feature: visual emphasis with a coarse semantic hint. If a highlight carries meaning a user must have — the current search match, an unresolved comment — back it with something in the accessibility tree, such as a live region announcing "3 of 12 matches", rather than relying on the highlight alone.
Browser support
From MDN's browser-compat-data at the time of writing.
| Chrome / Edge | Safari | Firefox | |
|---|---|---|---|
Highlight, HighlightRegistry | 105 | 17.2 | 140 |
::highlight() | 105 | 17.2 | 149 |
The gating version in Firefox is 149, not 140. Firefox shipped the JavaScript objects in 140 (June 2025) but the pseudo-element only in 149 (24 March 2026), which is the Baseline date for the feature. A feature detection that only checks for CSS.highlights would have reported success in Firefox 140 to 148 while painting nothing.
Detect both:
const canHighlight =
"highlights" in CSS && CSS.supports("selector(::highlight(example))");If a browser does not support the selector() function at all, that check returns false and you take the fallback path — which is the safe direction to fail in.
Where it is missing, fall back to wrapping matches in <mark> — the DOM cost you were avoiding, paid only by older browsers.
FAQ
Does it work inside <input> and <textarea>?
No. Ranges address nodes in the DOM, and the text inside a form control is not exposed as text nodes you can build a Range over. Highlighting inside a plain input still needs an overlay or a contenteditable element.
Will browser find-in-page still find the text?
Yes. The text is untouched in the DOM — that is the entire point — so the browser's own search behaves exactly as before.
Can I animate a highlight?
Not usefully. Transitions and animations are not in the applicable property list. You can swap between registered highlights, or animate a custom property that a highlight's color reads, but the reliable version is a discrete change.
How many ranges can I add?
The spec sets no limit. Practically, every live Range is something the browser must keep correct across DOM mutations, so prefer StaticRange for large numbers of one-shot highlights and rebuild them yourself.
Sources
- CSS Custom Highlight API Module Level 1 — registry, priority, overlap and type rules; read from its source in the csswg-drafts repository
- CSS Pseudo-Elements Module Level 4 — the list of properties that apply to highlight pseudo-elements; read from its source in the csswg-drafts repository
- mdn/browser-compat-data — support versions and release dates for
api.Highlightandcss.selectors.highlight - web-platform-dx/web-features — Baseline status and date for custom highlights
More to read

Design · · 8 min read
Two engines now style the real <select>. Here is how it works
Safari 27 shipped appearance: base-select on 14 September 2026, so Chrome and Safari both style the native dropdown. The parts, the rules and the traps.
- AI
AI · · 7 min read
MCP's 2026-07-28 revision is close to a rewrite. What it changes
The Model Context Protocol dropped sessions, the initialize handshake, ping and server-initiated requests. What changed, and why nothing breaks today.