Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More
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
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Syntax highlighting on the web has traditionally meant wrapping every token in a 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 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 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 via new Range() 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 element and -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.

function supportsCustomHighlights() {
  return (
    typeof Highlight !== 'undefined' &&
    typeof CSS !== 'undefined' &&
    CSS.highlights !== undefined
  );
}

if (supportsCustomHighlights()) {
  applyHighlightAPI();
} else {
  applySpanFallback();
}

This check covers both required interfaces. The fallback path, applySpanFallback(), would implement conventional 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 via JavaScript.

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 Source Code with JavaScript

Before anything gets highlighted, you need a tokenizer that maps a source string into a flat array of tokens, each carrying a type, start 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 (via the web-tree-sitter npm package) 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.

// Module-level constant — regex objects created once
const PATTERNS = Object.freeze([
  { type: 'comment',     regex: /\/\/.*|\/\*[\s\S]*?\*\//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: /\b\d+(\.\d+)?\b/g },
  { type: 'punctuation', regex: /[{}()[\];,.=+\-*\/<>!&|?:]/g },
]);

function overlaps(occupied, start, end) {
  // Binary search for last interval with .start <= start
  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;
  }
  // Check the found interval and its neighbour
  if (idx >= 0 && occupied[idx].end > start) return true;
  if (idx + 1 < occupied.length && occupied[idx + 1].start < end) return true;
  return false;
}

function tokenize(source) {
  const tokens   = [];
  const occupied = []; // kept sorted by start

  for (const { type, regex } of PATTERNS) {
    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; // guarantee reset even if exec throws
    }
  }

  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

 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.

function getTextNode(el) {
  const node = el.firstChild;
  if (!node) return null;
  if (node instanceof Text) return node;
  // contenteditable may insert 
or ; find first Text child
for (const child of el.childNodes) { if (child instanceof Text && child.length > 0) return child; } return null; } function createRangesByType(tokens, textNode) { const rangeMap = new Map(); for (const token of tokens) { const range = new Range(); 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
or 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 via CSS.highlights

Each entry in the range map becomes a Highlight instance. Add ranges via highlight.add(range) 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.

// Track only keys this component owns
const _ownedHighlightKeys = new Set();

function registerHighlights(rangeMap) {
  // Remove only keys we registered — never touch foreign highlights
  for (const key of _ownedHighlightKeys) {
    CSS.highlights.delete(key);
  }
  _ownedHighlightKeys.clear();

  for (const [type, ranges] of rangeMap) {
    const highlight = new Highlight();
    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.

const DEBOUNCE_MS = 100;

function debounce(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

 rather than . The input event listener targets  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.

DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Highlight API Demotitle>
<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>
<pre contenteditable="true"><code id="code">const greet = (name) => {
  // say hello
  return `Hello, ${name}!`;
}
const count = 42;code>pre>
<div id="stats">div>
<script>
(function() {
  // Feature detection guard
  if (
    typeof CSS === 'undefined' ||
    typeof CSS.highlights === 'undefined' ||
    typeof Highlight === 'undefined'
  ) {
    document.getElementById('stats').textContent =
      'Custom Highlight API not supported in this browser.';
    return;
  }

  var PATTERNS = [
    {type:'comment',regex:/\/\/.*|\/\*[\s\S]*?\*\//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:/\b\d+(\.\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) return true;
    if (idx + 1 < occupied.length && occupied[idx + 1].start < end) return true;
    return false;
  }

  function tokenize(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;});
  }

  function getTextNode(el) {
    var node = el.firstChild;
    if (!node) return null;
    if (node instanceof Text) return node;
    for (var i = 0; i < el.childNodes.length; i++) {
      if (el.childNodes[i] instanceof Text && el.childNodes[i].length > 0)
        return el.childNodes[i];
    }
    return null;
  }

  var _ownedKeys = new Set();

  function highlight() {
    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 = new Map();
    for (var i = 0; i < tokens.length; i++) {
      var tk = tokens[i];
      var r = new Range();
      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 = new Highlight();
      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; return function(){ 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 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 via the web-tree-sitter npm package) 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 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.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.