HomeJournalThis post

Custom Highlight API for Search and Annotations

Implement one annotation layer that paints without wrapper spans and keeps a parallel semantic list for navigation and assistive technology.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

The Custom Highlight API can paint ranges without turning every annotation into a wrapper span. The harder work is durable anchoring, overlap policy, and a semantic path people can navigate.

Custom Highlight API separates paint from structure

The Custom Highlight API lets an application paint DOM Ranges without inserting wrapper spans around every match. That separation is useful for search results and annotations crossing text nodes, but the highlights are visual styling; they do not create accessible controls, comments, or navigation on their own.

The CSS Custom Highlight specification defines highlight registries, pseudo-elements, and painting relationships. Feature-detect CSS.highlights and Highlight, then preserve a semantic representation even when the visual API is unavailable.

The lab uses one static two-paragraph document, three ranges, and a quote-context repair. It demonstrates a bounded anchoring strategy, not collaborative editing, arbitrary document synchronization, or guaranteed annotation survival across every mutation.

Painting and meaning are separate data products. A range registry can decorate text without wrapper elements, while a durable annotation still needs identity, author, purpose, source revision, quote context, status, and a semantic control that a keyboard or assistive technology can reach directly.

Range registry without wrappersThree native ranges include two true cross-node annotations while the document element count remains unchanged.search-hit · p10comment-a · p20comment-b · p30DOM child count unchanged
Range registry without wrappers
Three native ranges include two true cross-node annotations while the document element count remains unchanged.
  • search-hit: quote “Annotations”, original offsets 0–11, one text node, priority 10.
  • comment-a: quote offsets 52–100, spanning three text nodes, priority 20.
  • comment-b: quote offsets 141–167, spanning two text nodes, priority 30.
  • The artifact reads each priority and Range back from CSS.highlights; no wrapper is inserted.
Figure 1: Two ranges cross text nodes without turning annotations into DOM wrappers.

Define annotation identity before a Range

Give each annotation an immutable ID, kind, author or system owner, created time, status, exact quote, prefix and suffix context, original offsets, priority, and permission state. A live Range is a rendering projection that can become invalid as text changes; it should not be the only durable record.

Custom Highlight API names are registry keys, so derive a safe stable name rather than exposing user text or colliding across documents. Map IDs to styles and semantic list items in application state, and clean registry entries when a document unmounts or an annotation is revoked.

If text may include complex Unicode, start with Unicode product-interface foundations. Stored offsets need an explicit unit and normalization policy before they can be compared with DOM Range offsets.

DOM positions are cheap anchors inside one document revision and fragile identifiers across edits. Preserve exact quote, nearby prefix and suffix, and the original range; on reload, try the position first, verify its text, then search context and mark ambiguity instead of choosing silently.

Measure registry scale with the actual browser matrix. Thousands of ranges may increase registration, style, and paint cost even without wrapper nodes; paginate semantic controls, batch visual updates, and remove offscreen or resolved items when product semantics allow. Preserve counts and timing distributions, then recheck focus and navigation so a performance optimization does not make annotations unreachable.

Construct ranges across text nodes

Walk text nodes in document order and build a cumulative offset map. Resolve the annotation’s start and end into concrete node-plus-offset boundary points, ensure both nodes remain under the intended root, and create a Range only when start precedes end within the current document.

The DOM Range standard is the authority for boundary points and mutation behavior. Avoid innerHTML searches and wrapper insertion, which can disturb selection, focus, event delegation, layout, and assistive-technology interpretation.

The committed local fixture records the document’s element count before and after Custom Highlight API registration. A matching count proves this browser fixture avoided wrappers; it does not prove a full application has no other DOM mutation.

CSS custom highlights keep the source DOM clean because ranges live outside the element tree. That advantage matters for copy, layout, and framework reconciliation, but it does not eliminate range mutation rules or guarantee that every highlight purpose is announced by every accessibility stack.

Declare overlap priority

Search matches, comments, spelling marks, review states, and the user’s active selection can overlap. Define a priority policy by annotation kind and active state, then register or style highlights accordingly; DOM order alone is not a product rule and can shift as ranges are recreated.

Use patterns, underlines, outlines, or borders in addition to color when categories matter. Test combinations at real text sizes and forced-colors settings, because several translucent fills can erase contrast or make selected text unreadable.

The sample Custom Highlight API stack assigns search, editorial comment, and active annotation increasing priorities. Its keyboard list uses the same order, ensuring the semantic route explains what the paint stack communicates.

DOM Range annotations can span text nodes, so a global text index needs a reversible map back to node and offset pairs. Rebuild that map after deterministic mutations, reject offsets inside excluded content, and test boundaries at the start and end of adjacent nodes.

Runnable artifact — The lab demonstrates one anchoring strategy and does not guarantee collaborative-editor convergence, screen-reader exposure of every highlight type, or universal browser support.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Custom Highlight annotations lab</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.45 system-ui;background:#091318;color:#f4f7f6;max-width:980px;margin:auto;padding:24px}main{display:grid;gap:16px}fieldset,.panel{border:1px solid #8aa0aa;border-radius:12px;padding:14px}fieldset{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}label{display:grid;gap:5px}button,input,select,a,textarea{font:inherit;padding:9px}button,a{min-height:44px}textarea{width:100%;min-height:210px;background:#071014;color:#f4f7f6}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.status{padding:10px;border-left:5px solid #46e0c1;background:#10242b}svg,canvas{max-width:100%;height:auto}.sr{position:absolute;left:-9999px}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;scroll-behavior:auto!important}}body{background:#f6f1e8;color:#17201f}:root{color-scheme:light}::highlight(search-hit){background:#f4dc67}::highlight(comment-a){text-decoration:3px underline #007f86}::highlight(comment-b){text-shadow:0 0 0 #17201f;background:#f0b8ac}article{font-size:19px}.status,textarea{background:#fff;color:#17201f}</style><main><h1>Custom Highlight annotation registry</h1><p>Paint is disposable; anchors and semantic controls remain inspectable.</p><article id="source" class="panel"><span>Annotations can cross </span><strong>text nodes.</strong><span> A paragraph gives </span><span>ranges enough </span><em>structure to cross</em><span> text boundaries without wrapping the DOM. </span><span>Context keeps </span><strong>structure without</strong><span> wrapping unique after edits.</span></article><div class="grid"><button id="apply">Apply anchored highlights</button><button id="mutate">Mutate DOM and repair</button><button id="ambiguity">Run ambiguity guard</button><a id="downloadReceipt" download="highlight-receipt.json">Export current receipt</a></div><ol id="semanticList" aria-label="Annotation list"></ol><p id="status" class="status" aria-live="polite"></p><textarea id="receipt" readonly aria-label="Execution receipt"></textarea></main><script>const anchors=[{name:'search-hit',priority:10,prefix:'',quote:'Annotations',suffix:' can cross'},{name:'comment-a',priority:20,prefix:'paragraph gives ',quote:'ranges enough structure to cross text boundaries',suffix:' without wrapping'},{name:'comment-b',priority:30,prefix:'Context keeps ',quote:'structure without wrapping',suffix:' unique after edits.'}],source=document.getElementById('source'),semanticList=document.getElementById('semanticList'),receipt=document.getElementById('receipt'),statusNode=document.getElementById('status');let mutationApplied=false,ambiguityGuard=null;
function textMap(root){const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT),nodes=[];let text='',node;while(node=walker.nextNode()){nodes.push({node,start:text.length,end:text.length+node.data.length});text+=node.data}return{text,nodes}}
function locate(nodes,offset,end=false){const item=nodes.find((entry,index)=>offset<entry.end||(end&&offset===entry.end)||index===nodes.length-1);if(!item)throw Error('offset outside text map');return{node:item.node,offset:Math.max(0,Math.min(item.node.data.length,offset-item.start))}}
function resolveAnchor(text,anchor){const needle=anchor.prefix+anchor.quote+anchor.suffix,locations=[];let index=text.indexOf(needle);while(index>=0){locations.push(index);index=text.indexOf(needle,index+1)}if(locations.length!==1)return{ok:false,reason:locations.length===0?'context_not_found':'ambiguous_context',matches:locations.length};return{ok:true,start:locations[0]+anchor.prefix.length,end:locations[0]+anchor.prefix.length+anchor.quote.length,matches:1}}
function register(){const before=source.querySelectorAll('*').length,map=textMap(source),ranges=[];if(!('highlights'in CSS)||typeof Highlight!=='function'){return{supported:false,path:'semantic list only; no visual-equivalence claim',ranges:[],domElementCountBefore:before,domElementCountAfter:before}}CSS.highlights.clear();for(const anchor of anchors){const resolved=resolveAnchor(map.text,anchor);if(!resolved.ok)throw Error(anchor.name+': '+resolved.reason);const start=locate(map.nodes,resolved.start),end=locate(map.nodes,resolved.end,true),range=new Range();range.setStart(start.node,start.offset);range.setEnd(end.node,end.offset);if(range.toString()!==anchor.quote)throw Error(anchor.name+': range text mismatch');const highlight=new Highlight(range);highlight.priority=anchor.priority;CSS.highlights.set(anchor.name,highlight);const actual=CSS.highlights.get(anchor.name),actualRange=[...actual][0];ranges.push({name:anchor.name,priority:actual.priority,quote:actualRange.toString(),startNode:actualRange.startContainer.parentElement?.tagName||'#text',endNode:actualRange.endContainer.parentElement?.tagName||'#text',startOffset:actualRange.startOffset,endOffset:actualRange.endOffset,crossNode:actualRange.startContainer!==actualRange.endContainer,contextMatches:resolved.matches})}const after=source.querySelectorAll('*').length;return{supported:true,path:'native Custom Highlight registry',ranges,domElementCountBefore:before,domElementCountAfter:after}}
async function execute(mode='apply'){try{if(mode==='mutate'&&!mutationApplied){source.insertBefore(document.createTextNode('Edited intro. '),source.firstChild);mutationApplied=true}if(mode==='ambiguity'){const fixture='prefix '+anchors[0].quote+anchors[0].suffix+' and prefix '+anchors[0].quote+anchors[0].suffix;ambiguityGuard=resolveAnchor(fixture,{...anchors[0],prefix:'prefix '})}const registry=register(),data={...registry,repairApplied:mutationApplied,strategy:'unique prefix + quote + suffix context',ambiguityGuard:ambiguityGuard||resolveAnchor('prefix Annotations can cross and prefix Annotations can cross',{name:'guard',prefix:'prefix ',quote:'Annotations',suffix:' can cross'}),registryStateReadBack:registry.ranges.every(row=>row.priority===anchors.find(anchor=>anchor.name===row.name)?.priority&&row.quote===anchors.find(anchor=>anchor.name===row.name)?.quote),crossNodeRangeCount:registry.ranges.filter(row=>row.crossNode).length};if(data.supported&&(!data.registryStateReadBack||data.crossNodeRangeCount<2||data.domElementCountBefore!==data.domElementCountAfter||data.ambiguityGuard.reason!=='ambiguous_context'))throw Error('registry parity invariant failed');semanticList.innerHTML=anchors.map(anchor=>'<li><button data-anchor="'+anchor.name+'">'+anchor.name+' · priority '+anchor.priority+' · '+anchor.quote+'</button></li>').join('');statusNode.textContent=data.path+(mutationApplied?' · context repair applied':'');downloadReceipt.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));receipt.dataset.execution=JSON.stringify(data);receipt.value='PASS: '+JSON.stringify(data,null,2)}catch(error){receipt.dataset.execution=JSON.stringify({unexpectedError:error.name+': '+error.message});receipt.value='FAIL: unexpected '+error.message}}document.getElementById('apply').onclick=()=>void execute('apply');document.getElementById('mutate').onclick=()=>void execute('mutate');document.getElementById('ambiguity').onclick=()=>void execute('ambiguity');void execute();</script></html>

Build a semantic companion

Render annotations in an ordered region with text quote, kind, author when appropriate, status, and an action that moves focus or selection to the anchored passage. The control needs a meaningful name and a predictable focus target even though the painted pseudo-element cannot receive focus.

Browser focus foundations help maintain focus when navigating between list and document. Do not move focus merely because a highlight appeared, and avoid announcing every search repaint through a live region; streaming accessibility explains why update chatter can overwhelm users.

When native highlighting is unsupported, keep the semantic list and label the visual path unavailable. A wrapper-based fallback can be implemented separately, but it carries different mutation and accessibility risks and must not be called equivalent without tests.

Search result highlighting is transient and can tolerate rebuilding from the current query. A saved review comment cannot; it needs an unresolved state, repair history, and user-visible recovery path when the quote appears twice or disappears after an edit.

Security and privacy rules apply to annotation text and anchors. Exact quotes can contain personal or confidential content, while context windows may reveal more than the highlighted phrase. Encrypt or access-control durable comments, minimize exported context, and avoid placing quote text in telemetry, CSS names, URLs, or analytics attributes. A sanitized fixture should preserve boundary complexity without preserving user material.

Repair mutations with bounded quote context

After editing, first verify whether stored boundary points still select the exact quote. If not, search within a bounded region for the exact quote plus prefix and suffix context; accept one unique candidate, keep zero candidates orphaned, and keep multiple candidates ambiguous rather than attaching to the first convenient match.

Custom Highlight API does not solve anchoring. The lab inserts text before a stored phrase and recomputes one unique quote-context range, recording old and new offsets; this is intentionally simpler than operational transformation, CRDT positions, or robust document fingerprints.

Preserve every repair event with strategy, document version, candidate count, chosen range, and confidence. Users should be able to inspect or reattach an orphan instead of receiving a confidently misplaced comment.

Annotation overlap needs a product policy before paint priority. Define whether search, comment, and review ranges blend, stack, or suppress one another, then mirror the same ordering in the semantic list so color alone is not the only signal of which annotations coexist.

Overlap priority stackLayered ribbons show search, comment, and selection precedence plus a semantic annotation list.1 · search match2 · editorial comment3 · active selectionkeyboard list mirrors the visual stack
Overlap priority stack
Layered ribbons show search, comment, and selection precedence plus a semantic annotation list.
  1. Search match, priority 10, yellow pattern.
  2. Editorial comment, priority 20, cyan underline.
  3. Active annotation, priority 30, violet outline.
Figure 2: Overlap is a declared priority rule and not an accident of registration order.

Handle search separately from durable comments

Search matches can be regenerated from the current document and query, while editorial comments usually need durable identity and lifecycle. Give them different stores, priorities, cleanup, and privacy rules; clearing a search query should not delete a comment, and resolving a comment should not disturb current find results.

For virtualized content, keep offscreen annotation navigation truthful by mounting or scrolling the target before focus moves. A Range cannot point into DOM that does not exist, so the semantic list needs an asynchronous reveal contract and failure state.

Custom Highlight API registration should batch updates to avoid thrashing style recalculation on every keystroke. Measure large documents with the real annotation distribution rather than assuming wrapper-free paint has no performance cost.

Feature detection should label the actual execution path. When CSS.highlights is unavailable, the lab keeps the semantic list and can show a bounded non-paint fallback; it must not insert wrapper spans and then describe that altered DOM as equivalent native behavior.

Respect selection and editing

User selection, composition, spellcheck, find-in-page, and browser highlights coexist with application highlights. Test copying, caret movement, input-method composition, selection colors, and annotation activation so a decorative layer does not obscure the user’s primary editing feedback.

Do not intercept pointer events on pseudo-elements that cannot own semantic actions. Put controls in the companion list or a positioned popover anchored from measured Range rectangles, preserve Escape and focus return, and recompute geometry after scroll, resize, font load, and mutation.

The lab avoids an interactive popover to keep its proof narrow. It demonstrates paint, overlap, repair, and keyboard-accessible enumeration; a production comment surface needs additional dialog, focus, and collision tests.

Custom Highlight API annotations work when paint can be discarded and rebuilt from an owned model. Custom Highlight API annotations fail as a storage format: serialize anchors and intent, never Range objects or visual styles that have meaning only inside one live document.

Quote-context repairA fourteen-character prefix insertion shifts offsets, then exact prefix, quote, and suffix context rebuild each unique native Range.52–100 before edit66–114 repairedprefix + quote+ suffix guard
Quote-context repair
A fourteen-character prefix insertion shifts offsets, then exact prefix, quote, and suffix context rebuild each unique native Range.
comment-a repair receipt
FieldValue
Original quote offsets52–100
Mutation“Edited intro. ” inserted; fourteen UTF-16 units
Contextprefix “paragraph gives ”; suffix “ without wrapping”
Outcomeone match; repaired quote offsets 66–114
Ambiguous fixturetwo matches; rejected
Figure 3: Repair succeeds only for one contextual match; ambiguity remains unresolved.

Test the registry as state

Unit-test offset-to-boundary resolution, unique quote matching, ambiguity, deletion, normalization, overlap priority, and registry cleanup. Browser tests should assert the real API path when supported, the semantic-only path otherwise, unchanged DOM structure, visible patterns, focus navigation, zoom, forced colors, and no uncaught exception.

The MDN Custom Highlight guide is useful for examples and compatibility context, while the specification and DOM standard remain normative sources. Record executing browser and feature-detection outcome with screenshots so a fallback capture is never mislabeled native.

Export fixture annotations and repaired ranges without private comment bodies. Hash the document fixture, because identical offsets against different text are not reproducible evidence.

Keyboard navigation belongs to the companion controls rather than invisible colored pixels. Each control identifies its quote and purpose, scrolls the current range into view, moves focus predictably, and remains available when the range is unresolved so repair is possible without a pointer.

Mutation repair also needs version ordering. If several edits land between anchor creation and repair, bind the attempt to one document digest and refuse to commit a range calculated against a stale render. Collaborative systems can compare a CRDT-relative position with quote context and surface disagreement instead of letting either mechanism win silently. The article’s one insertion deliberately does not simulate that concurrency.

Ship annotations with an anchoring receipt

Document root identity, text version or digest, offset unit, normalization, exact quote, context, registry name, priority, style token, semantic control, repair strategy, candidate count, browser support, and orphan behavior. Assign owners for both visual styling and annotation data retention.

Run the Custom Highlight API lab, inspect all three overlaps, tab through the list, trigger the mutation, and verify the element count stays fixed. Then test the application’s hardest repeated phrases and cross-node boundaries before enabling automatic repairs.

Revisit when the highlight specification, DOM mutation model, browser support, editor architecture, Unicode policy, or accessibility pattern changes. Wrapper-free paint is elegant, but a trustworthy annotation remains a durable claim tied to text, context, and a human-navigable control.

Review the implementation after draft, browser accessibility mapping, Range behavior, or anchoring strategy changes. An overlap plate makes a strong social artifact, but the canonical article must retain mutation outcomes, unresolved examples, and the warning that paint is not semantic annotation infrastructure.