Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 8 min read

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 seen Failed 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 > a or :first-child can stop matching once extra elements appear. Copy and paste, textContent offsets 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:

  1. Range: the same DOM Range you get from document.createRange() or new Range(), with a start and end inside text nodes.
  2. Highlight: a set-like object that holds one or more ranges.
  3. CSS.highlights: a registry (a HighlightRegistry, which behaves like a Map) where you give each Highlight a 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-decoration and its longhands, text-shadow, and a handful of others. No padding, no border, 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 click on a highlight. You hit-test yourself: get the caret position from the pointer with document.caretPositionFromPoint() and check which range contains it, or compare the pointer against range.getClientRects(). A newer CSS.highlights.highlightsFromPoint() does this for you, but support is still partial (see below).
  • Priority. When highlights overlap, highlight.priority decides 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 has user-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 Range is a pair of node references. It can't be saved to localStorage or 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: localStorage by default, or a custom storage adapter (an object with load and save, plus optional create, update and delete) to sync with your backend. export() and import() move annotations around, and toW3C() 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 orphan event and stays in storage.
  • Notes: each highlight can carry a note, with a color and an indicator for highlights that have notes.
  • React: penciled/react exports an <Annotatable> component and a useAnnotations() 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 get select events 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.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.