Lezer + CSS Custom Highlight API
Syntax highlighting with ::highlight(). The DOM stays a plain <pre><code>text</code></pre>; token ranges are registered against named highlights.
Usage
<CodeBlock> is a server component: it takes the source text and a Lezer parser, runs the parser at render time, and ships only the resulting token ranges to the client - the parser itself never enters the browser bundle.
import { parser } from '@lezer/javascript';
import CodeBlock from '@/components/CodeBlock';
export default function Page() {
return (
<CodeBlock
code={`const greeting = 'hello';`}
parser={parser}
/>
);
}code accepts any ReactNode, not just a string. The text content is extracted for parsing, while the original nodes are rendered inside the <pre><code> - so you can interleave elements like links or regions and they'll still be highlighted:
<CodeBlock
parser={parser}
code={
<>
{`import { `}
<a href="https://lezer.codemirror.net/">parser</a>
{` } from '@lezer/javascript';`}
</>
}
/>Which renders as:
import { parser } from '@lezer/javascript';Crossing the client boundary
What actually gets serialized is two fields:
classes- a string array of highlight class names (lzh-kw,lzh-str, …).tokens- a flat number array, grouped per class:[classIdx, pairCount, Δstart, length, Δstart, length, …]. Starts are stored as deltas from the previous token in the same class, so the numbers stay small even in long files.
The client walks this array and registers each (start, length) pair against the corresponding CSS Custom Highlight - no per-token object allocation, no spans in the DOM.
Demos
- /plain-text - baseline:
<pre><code>with no highlighting. - /build-time - ranges computed in a server component at build time; serialized across the client boundary as a plain object.
- /build-time-compressed - same, but ranges are varint+base64 compressed to shrink the RSC payload.
- /html-string - server component emits an HTML string of
<span class="lzh-*">tokens viadangerouslySetInnerHTML. - /jsx-spans - server component returns the same spans as nested JSX children of
<code>. - /html-string-hydrated - server generates the highlighted HTML string and ships it as a prop; SSR renders plain text, the client swaps in the highlighted HTML after hydration.
- /editor -
contenteditablewith live re-parsing, optional incremental parsing. - /mui - MUI
CodeHighlighter(@mui/internal-docs-infra) for comparison. Uses starry-night to generate HAST on the server, compresses it with a custom DEFLATE encoding to cross the client boundary, renders plain text during SSR, and expands the HAST into tokens-to-spans after hydration.
Comparison
Sizes are real HTTP response bodies. pnpm measure runs next build, starts next start on port 3100, requests each variant with Accept-Encoding: gzip, deflate, br, and records the bytes received over the wire (compressed) and after decoding (uncompressed) into data/pageSizes.json. Pass a base URL (e.g. pnpm measure https://example.com) to skip the build and measure an already-running server instead. Last measured 2026-04-24T12:02:39.325Z.
| Variant | Uncompressed HTML | Compressed HTML | TTFB (ms) | FCP (ms) | LCP (ms) | INP (ms) | CLS | Before scroll (ms) | After scroll (ms) | Server-side highlighting | Browser support | Initial HTML highlighted | Interactivity | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Script | Layout | Paint | Script | Layout | Paint | ||||||||||||
| No highlighting | |||||||||||||||||
| /plain-text | 68.1KB | 5.5KB (br) | 104 | 193 | 193 | 8.0 | 0.000 | 81 | 19 | 1.1 | 54 | 1.8 | 25 | — | widely available | ✕ | React components |
| CSS Custom Highlight API | |||||||||||||||||
| /build-time | 103.8KB | 7KB (br) | 103 | 192 | 192 | 8.0 | 0.000 | 100 | 18 | 7.2 | 55 | 1.7 | 110 | ✓ | Baseline 2026 | ✕ | React components |
| /build-time-compressed | 89.4KB | 7.8KB (br) | 100 | 188 | 188 | 8.0 | 0.000 | 92 | 17 | 7.1 | 54 | 1.7 | 107 | ✓ | Baseline 2026 | ✕ | React components |
| Span-based | |||||||||||||||||
| /html-string | 648KB | 8.7KB (br) | 105 | 236 | 236 | 16 | 0.000 | 79 | 57 | 1.4 | 55 | 2.5 | 133 | ✓ | widely available | ✓ | event delegation |
| /html-string-hydrated | 466.8KB | 8.5KB (br) | 106 | 222 | 222 | 16 | 0.000 | 84 | 47 | 1.5 | 55 | 2.5 | 127 | ✓ | widely available | ✕ | event delegation |
| /jsx-spans | 926.2KB | 84.5KB (br) | 110 | 257 | 257 | 16 | 0.000 | 106 | 57 | 1.5 | 56 | 2.2 | 124 | ✓ | widely available | ✓ | React components |
| /mui | 68.1KB | 23KB (br) | 100 | 196 | 196 | 32 | 0.135 | 103 | 18 | 1.2 | 165 | 138 | 62 | ✓ | widely available | ✕ | React components |
Web Vitals (TTFB, FCP, LCP, INP, CLS) are collected by pnpm measure via Playwright: each variant is loaded in a real Chromium page, useReportWebVitals forwards metrics to the Node runner, and a synthetic click + tab keystroke trigger INP. Each variant is measured across 20 runs and the table shows the 75th percentile. Numbers reflect unthrottled local rendering.
The Before/After scroll timings come from a Chrome DevTools Protocol Tracing session over the same Playwright run. The page is loaded and left to settle, a performance.mark delimits the "before scroll" window, the runner scrolls through the page to the bottom and back, and a second mark closes the "after scroll" window. Trace events are bucketed by self-time into Script (JS execution, parsing, compile), Layout (style recalc, layout), and Paint (paint, composite, raster) - so you can see how much work each variant does at first render vs. during scroll. Each variant runs 20 times and the table shows the 75th percentile.
Trade-offs of the CSS Custom Highlight API
- A token is assigned to a single highlight - you can't combine class styles the way you would with stacked
classNames on a span. Slight mental-model shift: pick one class per range. ::highlight()only supports a limited set of CSS properties (colors, backgrounds,text-decoration, a few others - see MDN). Nofont-weight, nofont-style, no custom markers - so bold keywords or italic comments aren't available.