01 article

Zero Extra Nodes: The CSS Highlight API That Styles Any Text Range

Highlight arbitrary text ranges without touching the DOM: the CSS Custom Highlight API lets JavaScript build the ranges and CSS paint them. What to know before shipping it, from the styling whitelist to the StaticRange performance trap.

Zero Extra Nodes: The CSS Highlight API That Styles Any Text Range

Zero Extra Nodes: The CSS Highlight API That Styles Any Text Range

Highlighting text used to mean touching the DOM. You wrapped a match in a <mark>, split a text node so the highlighted fragment became its own element, or worse, rebuilt a paragraph with innerHTML and hoped the event listeners survived. Every one of those approaches changes the structure of the page, which means layout can shift, observers re-fire, and screen readers see nodes that were never really there. The CSS Custom Highlight API removes the DOM from the equation entirely.

JavaScript builds the ranges and CSS paints them, with nothing inserted into the document in between.

What the API actually is

The browser already ships a handful of highlight pseudo-elements you cannot easily control: ::selection, ::inactive-selection, ::spelling-error, ::grammar-error, and ::target-text for the fragment a search result scrolls you to. Each one styles a range the user agent defines for you. The Custom Highlight API adds ::highlight(), a pseudo-element that styles a range you define, registered under a name you choose. A custom highlight is just a set of Range or StaticRange objects, and it can cross element boundaries without honoring the nesting structure, which is exactly what makes it useful.

The four-step dance

Every highlight goes through the same sequence. Create the ranges, wrap them in a Highlight, register that Highlight on the HighlightRegistry, and style the registered name.

const text = document.querySelector("article p");
const range = new Range();
range.setStart(text, 0);
range.setEnd(text, 4);

const gold = new Highlight(range);
CSS.highlights.set("hl-gold", gold);
::highlight(hl-gold) {
  color: oklch(88% 0.16 75);
  text-shadow: 0 0 40px oklch(65% 0.22 75 / 0.4);
}

That is the whole API surface you will reach for. CSS.highlights is the registry, .set(name, highlight) registers, and .delete(name) removes it. One highlight can hold many ranges, so a search feature that finds forty matches still registers a single Highlight object with forty ranges inside it.

What you can and cannot style

The ::highlight() block is a whitelist, not a full stylesheet. Only a fixed set of properties takes effect:

  • color and background-color
  • text-decoration and its longhands
  • text-shadow
  • -webkit-text-stroke-color, -webkit-text-fill-color, and -webkit-text-stroke-width

Everything else is ignored. Set background-image, font-size, or padding and the browser quietly throws the value away, which is a common surprise the first time someone tries to build a highlighted badge with it. The constraint makes sense: a highlight is a paint layer over existing text, not a box, so it cannot change layout or carry an image.

Live ranges and the performance trap

A Range is live. When the DOM changes around it, the user agent adjusts the boundary points so the highlight tracks the text. That is convenient and it is expensive. If your content re-renders constantly, the browser is re-resolving every range on every mutation, and the spec says this plainly: updating all the ranges as the DOM changes has a significant performance cost.

The fix is StaticRange. A StaticRange snapshot does not move when the DOM changes, and you cannot edit it after creation. If you are already observing the DOM and reacting to it, the spec strongly recommends building StaticRange objects and recreating the whole set when the content actually changes, instead of paying the live-range tax on every update.

One more knob: the priority attribute. When highlights overlap, the higher priority paints on top. The default is zero, so if you layer a search match under a collaborator's cursor, set the cursor's priority higher and it wins.

The accessibility trap

Here is the part that will bite you in review. Custom highlights are purely presentational. They are not exposed to the accessibility tree by default, so a screen reader will not tell the user that a passage is highlighted. The type attribute (spelling-error, grammar-error, or highlight) is your only lever, and even that is only honored as much as the platform accessibility API can express it.

The practical rule: if the highlight carries meaning, do not use this API at all. Use a <mark>. Save the Custom Highlight API for things that are visual and transient: a search-as-you-type overlay, syntax coloring in a code viewer, a collaborator's position in a shared editor, a live spellcheck squiggle. Those do not need to survive in the semantic tree.

Support and the fallback you will ship

Support is the reason this is suddenly worth using. The CSS.highlights registry has been Baseline since mid-2025, and the ::highlight() pseudo-element reached Baseline in March 2026. That means Chrome and Edge have had it since version 105, Safari since 17.2, and Firefox finally joined in version 149 this spring, which is the first time all four engines have agreed on it.

Because the whole feature is JavaScript-first, you gate it on one check: if (CSS.highlights). When it is missing, fall back to the old way, but do it without clobbering the document. Walk the text nodes with a TreeWalker, split out each match into a <mark>, and assign the match text with textContent so a user-typed query never becomes an injection hole.

Wrap the pseudo-element rules in a feature query so unsupported browsers simply never see them:

@supports selector(::highlight(hl-gold)) {
  ::highlight(hl-gold) {
    background-color: yellow;
    color: black;
  }
}

When to reach for it

Reach for it whenever you would otherwise reach for innerHTML and a <mark>. Find-in-page over long or virtualized documents is the strongest case, because the highlight rides on the render layer and survives scroll recycling that would wreck wrapped nodes. Collaborative editing, multi-selection, and editor spellcheck all fit the same mold. Skip it for anything a user needs to understand from the accessibility tree, and skip it if you need the highlight to change layout, because it physically cannot.

Comments