Highlighting text without wrapping it in spans: the CSS Custom Highlight API
The CSS Custom Highlight API is a browser API that lets you style arbitrary ranges of text by registering Range objects in a Highlight, adding it to CSS.highlights under a name, and targeting that name with the ::highlig
The CSS Custom Highlight API is a browser API that lets you style arbitrary ranges of text by registering Range objects in a Highlight, adding it to CSS.highlights under a name, and targeting that name with the ::highlight() pseudo-element. The DOM stays exactly as it was: no <span>, no <mark>, no extra nodes.
I ran into this while building a highlights-and-notes feature, and it changed how I think about the problem. This post covers why the old approach hurts, how the native API works, what support looks like today, and what you still have to build yourself.
What is wrong with wrapping highlights in spans?
For about fifteen years, the standard way to highlight text on a web page was to split text nodes and wrap the pieces in elements. mark.js wraps matches in <mark>. Rangy's highlighter and web-highlighter wrap them in <span>. It works, but it has costs:
-
You are editing a DOM someone else owns. In React, Vue or Svelte, the framework assumes it controls the nodes it rendered. If a library splits a text node and inserts a
<span>, the next re-render can throw the highlight away, or fail outright when the framework tries to update a node that is no longer where it expected. If you've ever seenFailed to execute 'removeChild' on 'Node'after a third-party script touched your markup, you know what this looks like. - Overlapping highlights get messy. Two highlights that partly overlap need nested or split wrappers, and removing one means stitching text nodes back together correctly.
-
Selectors and styles change.
p > aor:first-childcan stop matching once extra elements appear. Copy and paste,textContentoffsets and screen readers can all pick up the new structure. - Cleanup is your problem. Every "remove highlight" has to unwrap and normalize the nodes, or the DOM slowly fills with fragments.
None of this is a bug in those libraries. When they were written, wrapping was the only way to paint text.
How does the CSS Custom Highlight API work?
There are three pieces:
-
Range: the same DOMRangeyou get fromdocument.createRange()ornew Range(), with a start and end inside text nodes. -
Highlight: a set-like object that holds one or more ranges. -
CSS.highlights: a registry (aHighlightRegistry, which behaves like aMap) where you give eachHighlighta name. CSS then styles that name with::highlight(name).
Here is the minimal version, with no library:
<p id="intro">The quick brown fox jumps over the lazy dog.</p>
::highlight(note) {
background-color: #fde68a;
color: inherit;
}
if (typeof CSS !== 'undefined' && 'highlights' in CSS) {
const textNode = document.getElementById('intro').firstChild;
const range = new Range();
range.setStart(textNode, 4); // start of "quick"
range.setEnd(textNode, 19); // end of "fox"
const highlight = new Highlight(range);
CSS.highlights.set('note', highlight);
// Later, to remove it:
// CSS.highlights.delete('note');
}
Open DevTools after running this: the <p> still has a single text node. The browser paints the range, but nothing in the tree changed, so React (or anything else) can re-render the paragraph and the highlight is still painted as long as the range points at live nodes.
A few things the spec deliberately limits:
-
Only some properties apply inside
::highlight():color,background-color,text-decorationand its longhands,text-shadow, and a handful of others. Nopadding, noborder, no layout changes. That is the point: painting, not layout. - Ranges are live. If the DOM under a range changes, the range moves or collapses with it. That's great for small edits and a problem when a framework replaces the whole node (more on that below).
-
Highlights are not elements, so they get no events. There's no
clickon a highlight. You hit-test yourself: get the caret position from the pointer withdocument.caretPositionFromPoint()and check which range contains it, or compare the pointer againstrange.getClientRects(). A newerCSS.highlights.highlightsFromPoint()does this for you, but support is still partial (see below). -
Priority. When highlights overlap,
highlight.prioritydecides which one paints on top.
Which browsers support the CSS Custom Highlight API?
Checked on 3 November 2026 against MDN's browser-compat-data and the web-features "Custom highlights" entry:
| Browser |
Highlight / CSS.highlights
|
::highlight() styling |
|---|---|---|
| Chrome / Edge | 105 (2022) | 105 |
| Safari (macOS and iOS) | 17.2 (December 2023) | 17.2 |
| Firefox | 140 |
partial from 140 to 148: text-decoration works from 146, text-shadow from 149. Full from 149 |
The feature became Baseline "newly available" on 24 March 2026, when Firefox 149 shipped full ::highlight() support. Two caveats worth knowing:
- In Safari,
::highlight()styles are ignored on text that also hasuser-select: none. -
CSS.highlights.highlightsFromPoint()is only in Chrome 140+ and Firefox 150+ (Safari has it in preview), so keep a manual hit-test fallback.
If you need to support older browsers, there is no polyfill that keeps the "no DOM changes" property. You either fall back to wrapping, or you simply don't show highlights there.
What do you still have to build yourself?
The native API paints ranges. That's all it does. For a real highlights-and-notes feature, you still need:
-
Persistence. A
Rangeis a pair of node references. It can't be saved tolocalStorageor a database. You need to serialize it into something stable. - Re-anchoring. When the page loads again, or the content was edited, or a framework re-rendered and replaced the text nodes, you have to find the text again and build fresh ranges. Character offsets alone break as soon as one word changes above your highlight.
- Selection UI. Turning a user's selection into a highlight, and showing a toolbar or note editor next to it.
- Hit-testing, as above, to open the note when someone clicks a highlight.
- Framework lifecycle. Creating and destroying everything when a component mounts and unmounts, and not running on the server during SSR.
How does penciled build on top of it?
This is the part where I talk about my own project. I wrote penciled (penciled.org, JavaScript highlights & notes library), an MIT-licensed library of about 15 kB that does the list above on top of the Custom Highlight API. It never inserts elements into your content, and it has no wrapping fallback.
The default setup, with a built-in toolbar and note editor:
import { annotate } from 'penciled';
import 'penciled/styles.css';
const penciled = annotate('#article', {
documentId: 'my-post',
storage: 'local', // or 'memory', or your own adapter with load/save
});
const off = penciled.on('create', (annotation) => {
console.log('created', annotation);
});
What it handles:
-
Persistence:
localStorageby default, or a custom storage adapter (an object withloadandsave, plus optionalcreate,updateanddelete) to sync with your backend.export()andimport()move annotations around, andtoW3C()converts them to the W3C Web Annotation format. -
Anchoring: each annotation stores the quoted text, a short prefix and suffix, and start/end positions. On load, penciled tries the stored position, then exact matches of the quote, then a fuzzy match, and only accepts a result above a confidence threshold. If the text really is gone, the annotation isn't silently painted in the wrong place. It's marked as an orphan, fires an
orphanevent and stays in storage. - Notes: each highlight can carry a note, with a color and an indicator for highlights that have notes.
-
React:
penciled/reactexports an<Annotatable>component and auseAnnotations()hook. It's SSR-safe, because the instance is only created in an effect, and React can re-render the annotated content as often as it likes:
import { Annotatable } from 'penciled/react';
import 'penciled/styles.css';
export function Post({ post, children }) {
return (
<Annotatable as="article" options={{ documentId: post.id }}>
{children}
</Annotatable>
);
}
-
Headless:
penciled/headless(about 6 kB) is the same engine without the UI. You getselectevents with the selected range and build your own toolbar:
import { annotate } from 'penciled/headless';
const penciled = annotate('#article');
penciled.on('select', (context) => {
if (!context || context.annotation || !context.editable) return;
// position your own button using context.range.getBoundingClientRect()
// then: penciled.create(context.range, { color: 'yellow' });
});
Highlights are registered as named highlights (penciled-<color>, penciled-<color>-noted, penciled-active), so you can style them with ordinary ::highlight() rules.
How does it compare to mark.js, Rangy and other options?
Honestly, it depends on what you need. These numbers come from my own comparison page, measured on 22 October 2026 (sizes are min+gzip):
| Library | How it paints | Size | Notes |
|---|---|---|---|
| mark.js | wraps in <mark>
|
~6.2 kB | Built for search-term highlighting, not saved annotations. Last release 8.11.1 in January 2018, still ~1.9M weekly downloads. |
| Rangy | wraps in <span>
|
~22.7 kB | A broad range/selection toolkit with a highlighter module. Latest 1.3.2. |
| web-highlighter | wraps in <span>
|
~9.4 kB | Persistence-oriented, and works back to IE11, which the native API can't. |
| @recogito/text-annotator | separate overlay layer | ~22.4 kB | BSD-3, W3C annotation model, strong for collaborative and scholarly annotation. |
| Annotator.js | jQuery-based | β | Last released in 2015. Hypothesis was originally built on it. |
| penciled | CSS Custom Highlight API | ~15 kB (~6 kB headless) | No DOM changes, persistence and fuzzy re-anchoring built in. Pre-1.0, with far fewer users (~640 weekly downloads). |
My rough guide:
- Highlighting search matches on a static page? mark.js is small and battle-tested.
- Need IE11 or very old browsers? You need a wrapping library such as web-highlighter or Rangy.
- Collaborative, shared annotation with accounts and groups? Look at Hypothesis or Recogito.
- Persistent personal highlights and notes in a modern app, especially one rendered by React, where you can't let a library rewrite your markup? That's the case I built penciled for.
If you only need to paint a few ranges, you don't need any library. The ten lines of native code above are enough.
Where can I try it?
Docs and a live demo are at penciled.org, and the source is on GitHub. There's also a free browser extension for Chrome, Firefox and Edge built on the same library, if you just want to highlight pages you read.
I'm Marcel Losso, the creator and maintainer of penciled, so weigh my comparison accordingly. Corrections and issues are very welcome on GitHub.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.