Close Menu
ToolTechBlogToolTechBlog

    Subscribe to Updates

    Get the latest creative news from FooBar about art, design and business.

    What's Hot

    Bose QuietComfort Headphones (2nd gen) review: Upgraded in all the right places

    September 11, 2026

    Jensen Huang explains why Nvidia will grow an astounding 70% next year

    September 11, 2026

    A Developer’s Look at Integrating AI Speech Into Applications

    September 11, 2026
    Facebook X (Twitter) Instagram
    ToolTechBlogToolTechBlog
    • Home
    • AI Tools
    • Web Hosting
    • Tech
    • Digital Marketing
    • Business Software
    • VPN & Cybersecurity
    ToolTechBlogToolTechBlog
    Home»Web Hosting»css custom highlight api javascript Tutorial
    Web Hosting

    css custom highlight api javascript Tutorial

    Tool Tech TeamBy Tool Tech TeamSeptember 6, 2026No Comments13 Mins Read
    Facebook Twitter Pinterest Telegram LinkedIn Tumblr WhatsApp Email
    css custom highlight api javascript Tutorial
    Share
    Facebook Twitter LinkedIn Pinterest Telegram Email

    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.

    Custom highlight JavaScript Tutorial
    Share. Facebook Twitter Pinterest LinkedIn Tumblr Email
    Tool Tech Team
    • Website

    Related Posts

    A Developer’s Look at Integrating AI Speech Into Applications

    September 11, 2026

    Build a Rust AI Agent Gateway with Tokio and Axum

    September 10, 2026

    Which AI recruiting tool fits your team in 2026?

    September 10, 2026

    WebGPU Shader Syntax Highlighting for Web IDEs

    September 9, 2026

    Dual-Read Cache Consistency in Monolith DB Migrations

    September 9, 2026

    Enforce TypeScript Architecture Boundaries via AST Import Graphs

    September 8, 2026
    Leave A Reply Cancel Reply

    Top posts
    Tech

    Bose QuietComfort Headphones (2nd gen) review: Upgraded in all the right places

    By Tool Tech Team
    Business Software

    Jensen Huang explains why Nvidia will grow an astounding 70% next year

    By Tool Tech Team
    Web Hosting

    A Developer’s Look at Integrating AI Speech Into Applications

    By Tool Tech Team
    Editors Picks

    Bose QuietComfort Headphones (2nd gen) review: Upgraded in all the right places

    September 11, 2026

    Jensen Huang explains why Nvidia will grow an astounding 70% next year

    September 11, 2026

    A Developer’s Look at Integrating AI Speech Into Applications

    September 11, 2026

    Powering AI is an architecture problem

    September 11, 2026
    About Us

    Welcome to ToolTechBlog, your trusted source for the latest insights, reviews, and practical guides on AI tools, business software, cybersecurity, web hosting, and consumer technology.
    Our mission is simple: to help individuals, entrepreneurs, freelancers, students, and businesses discover the right digital tools to improve productivity, streamline workflows, and make informed technology decisions.

    Our Picks

    Bose QuietComfort Headphones (2nd gen) review: Upgraded in all the right places

    September 11, 2026

    Jensen Huang explains why Nvidia will grow an astounding 70% next year

    September 11, 2026

    A Developer’s Look at Integrating AI Speech Into Applications

    September 11, 2026
    Top Reviews

    The AI Hype Index: Unsexy AI

    July 29, 2026

    What it is and How to Fix it

    July 29, 2026

    LG to Ban Residential Proxies from Smart TV Apps

    July 29, 2026

    © 2026 tooltechblog.com. All rights reserved. Designed by DD.

    • About Us
    • Contact Us
    • Terms and Conditions
    • Privacy Policy
    • Disclaimer

    Type above and press Enter to search. Press Esc to cancel.