CSS Custom Highlight Api: JavaScript Implementation Notes
<img src="https://tooltechblog.com/wp-content/uploads/2026/09/1584945800Group-5-2.png” alt=”SitePoint Team”>
SitePoint TeamPublished inHTML & CSS·JavaScript·Web·
September 6, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Syntax highlighting on the web has traditionally meant wrapping every token in a <span> element. The CSS Custom Highlight API and JavaScript offer a fundamentally different approach: styling arbitrary text ranges without inserting a single element into the document.
Table of Contents
Why Highlight Without the DOM?
Syntax highlighting on the web has traditionally meant wrapping every token in a <span> element. A moderately sized code block of 200 lines with roughly 8 tokens per line produces around 1,600 wrapper nodes, each triggering layout calculations, consuming memory, and complicating the DOM tree that assistive technologies must traverse. The CSS Custom Highlight API and JavaScript offer a fundamentally different approach: styling arbitrary text ranges without inserting a single element into the document.
The performance and complexity costs of DOM-heavy highlighting are real. Every inserted <span> forces the browser to recalculate styles, potentially triggers reflows, and inflates the DOM node count. Screen readers must parse through a forest of semantically meaningless wrapper elements, degrading the accessibility of what should be plain text.
The CSS Custom Highlight API, now shipping in major browsers, eliminates this overhead entirely. Developers define text ranges in JavaScript, group them into named highlights, and style them through CSS pseudo-elements. The DOM stays untouched.
This article walks through building a working syntax highlighter using only Range objects and ::highlight() pseudo-elements. The target audience is intermediate JavaScript and CSS developers with basic DOM API knowledge.
What Is the CSS Custom Highlight API?
Core Concepts: Ranges, Highlights, and Pseudo-Elements
The API uses three primitives that work together to decouple text styling from DOM structure. Range objects define start and end positions within text nodes. These are the same Range objects available through the standard DOM API, created or document.createRange(). Each Range pinpoints a substring of text content by referencing a node and character offsets
Highlight objects group multiple Ranges under a single logical unit. A Highlight instance accepts any number of Range or StaticRange objects and represents a collection of text segments that should receive the same visual treatment. The constructor signature is new Highlight(...ranges). On the CSS side, ::highlight(custom-id) styles all Ranges belonging to a named Highlight. When the browser encounters a registered Highlight, it paints the styles from that Highlight over the matching text ranges during the rendering phase, without altering the DOM tree. The paint happens on a dedicated highlight overlay layer, so the underlying node structure remains completely intact.
The W3C CSS Custom Highlight API Module Level 1 specification defines this mechanism. It has reached broad enough consensus for multi-browser implementation, though some details remain under active refinement. Integration with ::spelling-error and ::grammar-error pseudo-elements and the Highlight.type property are areas of ongoing work.
How It Differs from Traditional Approaches
The <mark> element and <span>-wrapping both require DOM mutation. Every highlighted segment becomes a new node, splitting existing text nodes and increasing tree depth. With contenteditable regions, these mutations interact unpredictably with user selections and input handling.
The Custom Highlight API requires zero DOM mutation. Highlight changes trigger no reflow, cause no text node fragmentation from element insertion, and create no interference with existing DOM structure. Screen readers see the original text hierarchy exactly as authored, since the highlights exist only in the rendering layer. Developers should verify behavior with target assistive technologies, as results vary by AT and browser combination. The semantic structure of the document stays completely preserved.
Checking Browser Support
Feature detection for the Custom Highlight API involves checking for the Highlight constructor and the CSS.highlights registry. Both must be present for the API to function.
Current browser compatibility: Chromium-based browsers (Chrome, Edge) support the API from version 105 onward. Verify exact feature completeness against the MDN compatibility table before targeting a minimum version. Safari added support in version 17.2; confirm against MDN’s Browser Compatibility table before shipping. Firefox has been tracking the specification but had not shipped full support at the time of writing; developers targeting Firefox should verify the current status against MDN’s compatibility tables.
A graceful fallback strategy should detect support at initialization and route to traditional span-based highlighting when the API is unavailable.
functionsupportsCustomHighlights(){return(typeofHighlight!=='undefined'&&typeofCSS!=='undefined'&&CSS.highlights!==undefined);}if(supportsCustomHighlights()){applyHighlightAPI();}else{applySpanFallback();}This check covers both required interfaces. The fallback path, applySpanFallback(), would implement conventional <span> wrapping with class-based styling.
Setting Up CSS ::highlight() Styles
The ::highlight() pseudo-element accepts a custom identifier that maps to a registered Highlight name. Declare these rules in a standard stylesheet; they apply automatically when the corresponding Highlight is registered
The set of CSS properties supported within ::highlight() is deliberately limited. Allowed properties include color, background-color, text-decoration, text-shadow, and font-style. Additional properties such as caret-color and text-emphasis-color are also specified. Consult the W3C specification for the normative list. Notably excluded are layout-affecting properties like font-weight, font-size, display, padding, and margin. This restriction exists because highlights must not alter document layout; they operate purely at the paint level.
Naming conventions for highlight identifiers should follow a consistent pattern. Using token type names directly keeps the mapping between CSS and JavaScript clear.
::highlight(keyword){color:#c678dd;background-color:transparent;}::highlight(string){color:#98c379;}::highlight(comment){color:#7f848e;font-style: italic;}::highlight(number){color:#d19a66;}::highlight(punctuation){color:#abb2bf;}font-style is supported within ::highlight() in current implementations (Chrome 105+, Safari 17.2+). Verify rendering in all target browsers before shipping.
Building a DOM-Free Syntax Highlighter Step by Step
Step 1: Tokenizing
Before anything gets highlighted, you need a tokenizer that maps aart offset, and end offset. For a demonstration-grade highlighter, regex patterns suffice. Production implementations would benefit from a proper parser like Tree-sitter compiled to WASM or the tokenization output from Prism.js
The approach here draws inspiration from lightweight highlighting tools like hc2html (https://github.com/stachon/hc2html), which use pattern matching to classify code tokens without building an AST. Note: hc2html uses DOM-based highlighting, not the Custom Highlight API; it is referenced only for its tokenization approach.
constPATTERNS=Object.freeze([{type:'comment',regex:///.*|/*[sS]*?*//g},{type:'string',regex:/(["'`])(?:(?!1|\).|\.)*1/g},{type:'keyword',regex:/b(const|let|var|function|return|if|else|for|while|class|import|export|default|new|this|typeof|instanceof)b/g},{type:'number',regex:/bd+(.d+)?b/g},{type:'punctuation',regex:/[{}()[];,.=+-*/<>!&|?:]/g},]);functionoverlaps(occupied, start, end){let lo =0, hi = occupied.length-1, idx =-1;while(lo <= hi){const mid =(lo + hi)>>>1;if(occupied[mid].start<= start){ idx = mid; lo = mid +1;}else hi = mid -1;}if(idx >=0&& occupied[idx].end> start)returntrue;if(idx +1< occupied.length&& occupied[idx +1].start< end)returntrue;returnfalse;}functiontokenize(source){const tokens =[];const occupied =[];for(const{ type, regex }ofPATTERNS){regex.lastIndex=0;try{let match;while((match = regex.exec(source))!==null){const start = match.index;const end = start + match[0].length;if(!overlaps(occupied, start, end)){tokens.push({ type, start, end });occupied.push({ start, end });occupied.sort((a, b)=> a.start- b.start);}}}finally{regex.lastIndex=0;}}return tokens.sort((a, b)=> a.start- b.start);}A higher-priority pattern claims the character sequence first, preventing later patterns from re-classifying it. Pattern order in the PATTERNS array determines priority, with comments and strings processed first. The overlap detection uses a sorted array with binary search for O(n log n) performance rather than a naive O(n²) scan. Note that this overlap detection only prevents full re-classification of already-matched ranges; partial overlaps where a later pattern matches a substring of an earlier match are not handled. For production use, a proper parser is recommended.
Step 2: Creating Range Objects for Each Token
With tokens in hand, create Range objects that reference the actual text node inside a <pre><code> container. This step assumes the container holds a single text node. If the DOM has been manipulated such that whitespace or other content creates sibling text nodes, calling element.normalize() first will merge adjacent text nodes into one. Note: normalize() invalidates any existing live Range objects and Selection anchors within the affected nodes.
functiongetTextNode(el){const node = el.firstChild;if(!node)returnnull;if(node instanceofText)return node;for(const child of el.childNodes){if(child instanceofText&& child.length>0)return child;}returnnull;}functioncreateRangesByType(tokens, textNode){const rangeMap =newMap();for(const token of tokens){const range =newRange();range.setStart(textNode, token.start);range.setEnd(textNode, token.end);if(!rangeMap.has(token.type)){rangeMap.set(token.type,[]);}rangeMap.get(token.type).push(range);}return rangeMap;}The getTextNode helper safely locates the first Text node child, handling cases where contenteditable browsers insert <br> or <span> elements as the first child. The function returns a Map keyed by token type, with each value being an array of Range objects. This grouping aligns directly with how the Highlight API expects ranges to be organized: one Highlight per visual style.
When dealing with multi-text-node scenarios (for instance, in a contenteditable element where the browser has split text nodes after user edits), a tree walker approach is necessary to locate the correct text node and offset for each token boundary. For this implementation, normalize() before tokenization keeps things straightforward.
Step 3: Registering Highlights
Each entry in the range map becomes a Highlight instance. Add ranges rather than spreading into the constructor to avoid hitting JavaScript engine argument count limits with large token sets. Register each highlight with CSS.highlights.set(), where the string key must match the identifier used in the corresponding ::highlight() CSS rule
const _ownedHighlightKeys =newSet();functionregisterHighlights(rangeMap){for(const key of _ownedHighlightKeys){CSS.highlights.delete(key);}_ownedHighlightKeys.clear();for(const[type, ranges]of rangeMap){const highlight =newHighlight();for(const r of ranges) highlight.add(r);CSS.highlights.set(type, highlight);_ownedHighlightKeys.add(type);}}This implementation tracks its own highlight keys and only deletes those on update, preserving any highlights registered by other components or browser features. Once set() completes, the browser immediately applies the matching ::highlight() styles during the next paint cycle. No explicit repaint request is needed.
Step 4: Updating Highlights on Content Change
For editable code blocks, highlights must update as the user types. The pattern is: clear existing highlights, re-read the text content, re-tokenize, recreate ranges, and re-register. A debounce prevents excessive work during rapid keystrokes, but the right delay depends on content size.
constDEBOUNCE_MS=100;functiondebounce(fn, delay){let timer;return(...args)=>{clearTimeout(timer);timer =setTimeout(()=>fn(...args), delay);};}const codeBlock =document.querySelector('pre[contenteditable] code');if(!codeBlock){console.warn('[highlighter] No matching contenteditable code block found.');}else{codeBlock.addEventListener('input',debounce(()=>{codeBlock.normalize();const textNode =getTextNode(codeBlock);if(!textNode)return;const source = textNode.data;const tokens =tokenize(source);const rangeMap =createRangesByType(tokens, textNode);registerHighlights(rangeMap);},DEBOUNCE_MS));}The normalize() call is essential here. User edits in contenteditable elements frequently fragment text nodes, and the tokenizer’s offsets assume a single contiguous text node. After normalize(), the source is read from textNode.data (the actual merged Text node) rather than el.textContent to ensure offset parity between the tokenized string and the node used for Range positioning. The 100 ms debounce is short enough to feel responsive on blocks under roughly 500 lines; profile larger inputs and increase the delay if you see frame drops.
The contenteditable attribute is intentionally on <pre> rather than <code>. The input event listener targets <code> and relies on event bubbling. Behavior varies across browsers; test in all targets before shipping.
Putting the Steps Together
The step-by-step code above defines the individual functions. To wire them together into a working highlighter, use an initialization entry point:
document.addEventListener('DOMContentLoaded',()=>{const el =document.querySelector('pre code');if(!el)return;if(!supportsCustomHighlights()){applySpanFallback();return;}el.normalize();const textNode =getTextNode(el);if(!textNode)return;const source = textNode.data;const tokens =tokenize(source);const rangeMap =createRangesByType(tokens, textNode);registerHighlights(rangeMap);});This ties together Steps 1 through 4 and ensures the highlighter initializes after the DOM is ready.
Interactive Demo: Live Range Inspection and Rendering Stats
The following self-contained demo combines the tokenizer, highlight registration, and a stats panel into a single working example. It displays the number of tokens by type, total Range objects created, and the time taken to register highlights.
Always read textContent, never innerHTML, from contenteditable blocks when processing user input. innerHTML from a contenteditable element may contain injected markup.
Readers can paste their own JavaScript into the contenteditable block and observe that zero DOM wrapper elements are created. Clicking any highlighted token in a full implementation would reveal its startOffset, endOffset, startContainer, and commonAncestorContainer.
<!DOCTYPEhtml><htmllang="en"><head><metacharset="UTF-8"><title>Custom Highlight API Demo</title><style>pre{background:#282c34;color:#abb2bf;padding:16px;font-size:14px;}::highlight(keyword){color:#c678dd;}::highlight(string){color:#98c379;}::highlight(comment){color:#7f848e;}::highlight(number){color:#d19a66;}::highlight(punctuation){color:#abb2bf;}#stats{font-family: monospace;margin-top:8px;font-size:13px;}</style></head><body><precontenteditable="true"><codeid="code">const greet = (name) => {// say helloreturn `Hello, ${name}!`;}const count = 42;</code></pre><divid="stats"></div><script>(function(){if(typeofCSS==='undefined'||typeofCSS.highlights==='undefined'||typeofHighlight==='undefined'){document.getElementById('stats').textContent='Custom Highlight API not supported in this browser.';return;}varPATTERNS=[{type:'comment',regex:///.*|/*[sS]*?*//g},{type:'string',regex:/(["'`])(?:(?!1|\).|\.)*1/g},{type:'keyword',regex:/b(const|let|var|function|return|if|else|for|while|class|import|export|default|new|this|typeof|instanceof)b/g},{type:'number',regex:/bd+(.d+)?b/g},{type:'punctuation',regex:/[{}()[];,.=+-*/<>!&|?:]/g}];function_overlaps(occupied, start, end){var lo =0, hi = occupied.length-1, idx =-1;while(lo <= hi){var mid =(lo + hi)>>>1;if(occupied[mid].start<= start){ idx = mid; lo = mid +1;}else hi = mid -1;}if(idx >=0&& occupied[idx].end> start)returntrue;if(idx +1< occupied.length&& occupied[idx +1].start< end)returntrue;returnfalse;}functiontokenize(s){var t =[], occupied =[];for(var i =0; i <PATTERNS.length; i++){var type =PATTERNS[i].type, regex =PATTERNS[i].regex;regex.lastIndex=0;try{var x;while((x = regex.exec(s))!==null){var a = x.index, b = a + x[0].length;if(!_overlaps(occupied, a, b)){t.push({type:type,start:a,end:b});occupied.push({start:a,end:b});occupied.sort(function(c,d){return c.start-d.start;});}}}finally{regex.lastIndex=0;}}return t.sort(function(a,b){return a.start-b.start;});}functiongetTextNode(el){var node = el.firstChild;if(!node)returnnull;if(node instanceofText)return node;for(var i =0; i < el.childNodes.length; i++){if(el.childNodes[i]instanceofText&& el.childNodes[i].length>0)return el.childNodes[i];}returnnull;}var _ownedKeys =newSet();functionhighlight(){var el =document.getElementById('code');if(!el)return;el.normalize();var tn =getTextNode(el);if(!tn)return;var src = tn.data;var t0 =performance.now();var tokens =tokenize(src);var rm =newMap();for(var i =0; i < tokens.length; i++){var tk = tokens[i];var r =newRange();r.setStart(tn, tk.start);r.setEnd(tn, tk.end);if(!rm.has(tk.type)) rm.set(tk.type,[]);rm.get(tk.type).push(r);}for(var key of _ownedKeys)CSS.highlights.delete(key);_ownedKeys.clear();for(var entry of rm){var h =newHighlight();for(var j =0; j < entry[1].length; j++) h.add(entry[1][j]);CSS.highlights.set(entry[0], h);_ownedKeys.add(entry[0]);}var t1 =performance.now();var total = tokens.length;var byType ={};for(var e of rm) byType[e[0]]= e[1].length;document.getElementById('stats').textContent='Tokens: '+ total +' | By type: '+JSON.stringify(byType)+' | DOM wrappers: 0 | Highlight time: '+(t1-t0).toFixed(2)+'ms';}highlight();document.getElementById('code').addEventListener('input',(function(){var t;returnfunction(){clearTimeout(t); t =setTimeout(highlight,100);};})());})();</script></body></html>The stats panel reports performance.now() timing for the full tokenize-and-register cycle. To benchmark span insertion, extend the demo by implementing an equivalent function that wraps tokens in <span> elements and wraps both approaches in performance.now() calls for comparison.
Performance Considerations and Gotchas
When Custom Highlights Outperform Span Wrapping
Run the demo above with 500 or more tokens to see the difference directly. Span-based highlighting creates one new DOM node per token, each requiring style calculation and layout. The Custom Highlight API creates zero new nodes. Memory stays flat relative to the DOM approach because Range objects store two node references and two integer offsets, not full DOM elements.
Paint performance benefits from the browser’s internal optimization of the highlight overlay layer. Highlight changes skip layout recalculation entirely and trigger only a repaint pass, though repaint cost scales with painted area.
This works because no layout-affecting properties can be set through ::highlight().
Known Limitations and Edge Cases
The limited set of styleable properties is the most significant constraint. Properties like font-weight, font-size, display, and padding cannot be applied through ::highlight(). Custom highlights cannot change text sizing, add borders, or alter box model dimensions.
The Highlight.priority property resolves overlapping Ranges. When two Highlights cover the same text, the one with the higher priority value paints on top. Without explicit priority, the browser paints equal-priority highlights in registration order: the last highlight registered in CSS.highlights renders on top.
Text node fragmentation remains a persistent pitfall. If code between tokenization and range creation alters the DOM (inserting elements, splitting text nodes), all Range offsets become invalid. Calling normalize() immediately before range creation prevents this class of bugs. Block-level styling is entirely unsupported; the API operates at the inline text decoration level only.
Practical Tips for Production Use
For real language grammars, replace the regex tokenizer shown here with a robust parser. Tree-sitter compiled to WASM (available provides accurate, incremental tokenization for dozens of languages. Alternatively, consuming the token stream from Prism.js (without its DOM rendering) offers a middle path
Use Highlight.priority to layer overlapping highlights. A common scenario: search-result highlighting (high priority) rendered on top of syntax coloring (lower priority). Pair the API with MutationObserver for containers where external code may modify content. The observer can trigger re-highlighting when childList or characterData mutations occur.
Progressive enhancement remains the safest deployment strategy: detect support, apply Custom Highlight API styling when available, and fall back to span injection otherwise.
What Comes Next
The CSS Custom Highlight API eliminates DOM pollution for text styling use cases. It decouples visual presentation from document structure in a way that <span> wrapping never could.
Beyond syntax highlighting, the same mechanism extends naturally to search-and-highlight features. Collaborative editing is a more ambitious application, where each participant’s cursor and selection could map to a named Highlight with a distinct priority and color, though conflict resolution across concurrent edits adds real complexity. Reading-progress indicators that repaint as users scroll are a simpler win.
The biggest open question is when Firefox will ship full support and whether the spec will expand the set of styleable properties without compromising the “no layout side effects” guarantee.
Sharing our passion for building incredible internet things.


