Why highlight pseudo-elements matter now
For years, styling selected or found text in the browser was a black box. You could ::selection and that was basically it. Everything else — search matches, spell-check squiggles, target-text jumps — was locked behind vendor internals.
The CSS Custom Highlight API changed that. Combined with pseudo-elements like ::search-text, ::highlight(), ::target-text, ::spelling-error, and ::grammar-error, you can now target text ranges that were never wrapped in an element, and style them with plain CSS.
This shifts a whole class of UI problems — search result highlighting, reader modes, annotation tools, collaborative editors — from "hack with <span> injection" to "register a Range, style a pseudo-element". If you've ever written a mark.js wrapper just to color a search hit, this is the moment to reconsider your stack. For a broader look at how the platform is evolving around text rendering, see this deep dive into CSS highlight pseudo-elements.

The pseudo-elements you need to know
Here's the current landscape, from most stable to most experimental:
| Pseudo-element | Purpose | Support |
|---|---|---|
::selection | User-selected text | Universal |
::target-text | Text scrolled to via URL fragment | Chrome, Safari |
::search-text | Browser's find-in-page matches | Chromium |
::spelling-error / ::grammar-error | Native spell/grammar markers | Chromium |
::highlight(name) | Custom ranges registered via JS | Chrome 105+, Safari 17.2+ |
Styling find-in-page results
::search-text is the one people have wanted for a decade. Browser find-in-page highlights used to be untouched by CSS. Now:
/* Default search highlight */
::search-text {
background: #ffe066;
color: #1a1a1a;
}
/* The currently focused match (the one Enter jumps to) */
::search-text:current {
background: #ff7043;
color: #fff;
outline: 2px solid #ff7043;
}
Custom Highlight API in practice
The real power move is registering your own ranges. No DOM mutation, no <mark> tags, no reflow storms on large documents.
// 1. Find the range you want to highlight
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const ranges = [];
let node;
while ((node = walker.nextNode())) {
const idx = node.textContent.indexOf('pseudo-element');
if (idx !== -1) {
const range = new Range();
range.setStart(node, idx);
range.setEnd(node, idx + 'pseudo-element'.length);
ranges.push(range);
}
}
// 2. Register them under a named highlight
const highlight = new Highlight(...ranges);
CSS.highlights.set('search-term', highlight);
/* 3. Style the named highlight */
::highlight(search-term) {
background-color: #fff3a0;
text-decoration: underline wavy #e67e22;
}
That's it. No wrapper elements, no cleanup when the search term changes — just CSS.highlights.clear() and re-register.
The MicroLighter pattern
A neat trick that's been circulating: build a syntax highlighter entirely on top of the Custom Highlight API. Instead of emitting <span class="keyword"> for every token, you tokenize the text, build Range objects, and register them under highlights like keyword, string, comment. The CSS then does the coloring. This keeps the DOM tiny and makes re-highlighting on theme switch basically free.

Caveats, limits, and what to watch out for
A few things nobody mentions in the happy-path demos:
- Only a subset of CSS applies. Highlight pseudo-elements are limited to a small set of properties:
color,background-color,text-decorationand its longhands,text-shadow, and a few others. You cannot setpadding,border,font-size, ordisplay. If you need a box around a match, this is not the tool. - No
::before/::after. You can't inject content into a highlight. It's styling only. - Ranges must be live. If the DOM changes, your ranges may become invalid and silently stop rendering. Re-register on mutation if your content is dynamic.
::search-textis Chromium-only right now. Firefox and Safari haven't shipped it. Feature-detect withCSS.supports('selector(::search-text)')and fall back gracefully.- Accessibility: highlights are visual only. Screen readers won't announce them. If the highlight conveys meaning (e.g., search matches), replicate it in ARIA live regions or the accessible name.
Progressive enhancement recipe
/* Baseline: works everywhere */
mark.search-hit {
background: #ffe066;
}
/* Upgrade: no DOM wrappers needed */
@supports selector(::highlight(search-term)) {
::highlight(search-term) {
background: #ffe066;
}
}
Ship the <mark> fallback, layer the highlight on top when supported, and delete the fallback when Baseline catches up. For a parallel story of how a platform-level API reshaped an entire stack, the WhatsApp Rust scaling case study is worth a read — same pattern of "new primitive unlocks a simpler architecture".
Where to go next
- Read the CSS Custom Highlight API spec — it's short and readable.
- Try
::target-textfor deep-link highlighting in documentation sites. - Build a small annotation tool: select text with
window.getSelection(), convert to aRange, register a highlight. You'll learn the whole API in an afternoon. - Watch the
::search-textand::spelling-errorspecs — as they land in more engines, the "wrap in a span" era officially ends.

The bottom line
CSS highlight pseudo-elements are one of those quiet platform additions that remove entire categories of JavaScript. If your codebase still injects <mark> tags for search results, or ships a 40KB highlighting library just to color tokens, the Custom Highlight API is worth a serious look this quarter.
Start with ::highlight() for your own ranges, layer ::search-text and ::target-text where supported, and keep a <mark> fallback for the long tail. The DOM you don't create is the DOM you don't have to clean up.