Skip to content

Repository files navigation

TanStack Highlight

TanStack Highlight

Tiny, synchronous syntax highlighting for blogs and documentation.

  • Import only the languages you use.
  • Run the same code during SSR and in the browser.
  • Emit one small, class-based HTML tree for every theme.
  • Highlight embedded <script>, <style>, and Markdown fence regions when their languages are registered.
  • Decorate lines and character ranges without a transformer framework.

This is not an editor parser or a TextMate engine. It is a deliberately small docs highlighter.

Documentation

Install

pnpm add @tanstack/highlight

If you use an AI agent, run npx @tanstack/intent@latest install.

Selective Languages

import { createHighlighter } from '@tanstack/highlight/core'
import { css } from '@tanstack/highlight/languages/css'
import { html } from '@tanstack/highlight/languages/html'
import { ts } from '@tanstack/highlight/languages/ts'
import { tsx } from '@tanstack/highlight/languages/tsx'

export const highlighter = createHighlighter({
  languages: [css, html, ts, tsx],
})

const result = highlighter.highlight(`const node = <Button />`, {
  lang: 'tsx',
})

html does not import script or style languages. Registering js, ts, or css enables delegation for matching embedded regions without increasing the standalone HTML module.

For convenience, the root entry contains every shipped language:

import { highlight } from '@tanstack/highlight'

const result = highlight(`const value = 'docs'`, { lang: 'ts' })

Unknown languages fall back to escaped plaintext.

SSR And Client

Create one highlighter in an isomorphic module and import that module from both rendering paths:

// highlight.ts
import { createHighlighter } from '@tanstack/highlight/core'
import { css } from '@tanstack/highlight/languages/css'
import { html } from '@tanstack/highlight/languages/html'
import { js } from '@tanstack/highlight/languages/js'
import { ts } from '@tanstack/highlight/languages/ts'
import { tsx } from '@tanstack/highlight/languages/tsx'

export const highlighter = createHighlighter({
  languages: [css, html, js, ts, tsx],
})

The API is synchronous and deterministic. Server output and hydrated client output are identical when they use the same registrations and options.

Markdown Pipelines

Adapters take an explicit highlighter, so importing one never pulls in the all-language build.

TanStack Markdown owns its <pre><code> containers. Use the dedicated adapter so Highlight returns only escaped inner token markup:

import { createTanStackMarkdownHighlighter } from '@tanstack/highlight/markdown'
import { highlighter } from './highlight'

export const highlightMarkdownCode =
  createTanStackMarkdownHighlighter(highlighter)
import { remarkHighlightCodeBlocks } from '@tanstack/highlight/remark'
import { highlighter } from './highlight'

const plugin = remarkHighlightCodeBlocks({ highlighter })
import { rehypeHighlightCodeBlocks } from '@tanstack/highlight/rehype'
import { highlighter } from './highlight'

const plugin = rehypeHighlightCodeBlocks({ highlighter })

The remark adapter emits standard HAST data rather than raw HTML. The rehype adapter replaces <pre><code class="language-*"> nodes and is idempotent.

Octane

The isolated Octane entry supports direct components and @octanejs/mdx without a framework dependency:

import { octaneMdx } from '@octanejs/mdx/vite'
import { createOctaneMdxHighlight } from '@tanstack/highlight/octane'
import { highlighter } from './highlight'

octaneMdx({
  rehypePlugins: [createOctaneMdxHighlight({ highlighter })],
})

The plugin is synchronous, so it works with both compileMdx() and compileMdxSync(). Direct Octane components can use createHighlightedCodeBlockProps() from the same entry to receive an escaped dangerouslySetInnerHTML payload.

Fence metadata supports titles, line numbers, and common line annotations:

```tsx title="App.tsx" {2,4-6} ins={8} del={9} error={11} lineNumbers
```

Supported annotation names are highlight, ins, del, focus, error, and warning.

Decorations

Programmatic decorations can target one or more lines or an exact character range:

const code = `const first = 1\nconst second = 2`
const start = code.indexOf('second')

highlighter.highlight(code, {
  lang: 'ts',
  lineNumbers: true,
  decorations: [
    { lines: 2, className: 'is-focused', data: { kind: 'focus' } },
    { range: [start, start + 6], className: 'is-error' },
  ],
})

Overlapping range decorations are split into valid nested spans in declaration order. Decoration data is escaped and emitted as data-* attributes.

Themes

Themes are isolated imports. The root and language entries contain no theme code.

import { createThemeCss } from '@tanstack/highlight/theme'
import { draculaTheme } from '@tanstack/highlight/themes/dracula'
import { githubLightTheme } from '@tanstack/highlight/themes/github-light'

const css = createThemeCss({
  light: githubLightTheme,
  dark: draculaTheme,
  darkSelector: '.dark',
})

For TanStack Markdown, set codeBlockSelector to its pre.tm-code wrapper and lineNumbersSelector to .tm-code--line-numbers. See the Markdown pipeline guide.

Available themes: Aurora X, Dracula, GitHub Dark, GitHub Light, Gruvbox Dark, Gruvbox Light, Monokai, Nord, One Dark Pro, Solarized Dark, and Solarized Light.

Languages

apache, cmake, cpp, css, diff, dockerfile, ejs, env, go, html, http, js, json, jsx, markdown, mermaid, nginx, php, plaintext, python, scheme, shell, sql, svelte, toml, ts, tsrx, tsx, vue, and yaml.

Each language is available from @tanstack/highlight/languages/<name>. The aggregate @tanstack/highlight/languages entry can tree-shake, while direct subpaths make isolation explicit. Importing only core helpers from the root entry also removes unused language registrations in a compatible bundler.

Output Contract

  • One <pre><code> tree per block.
  • No inline styles or colors.
  • Stable th-* semantic token classes.
  • Source text is preserved exactly by tokenization.
  • HTML and decoration attributes are escaped.
  • Light/dark switching is CSS-only and does not duplicate markup.

Size And Speed

Local browser bundles, minified with esbuild and compressed independently. KB uses 1,000 bytes:

Registration Minified Gzip Brotli
Core, no languages 3.84 KB 1.82 KB 1.66 KB
Core + TSX 9.46 KB 4.03 KB 3.66 KB
Octane MDX + TypeScript 13.21 KB 5.37 KB 4.90 KB
Nine-language docs set 15.39 KB 5.97 KB 5.44 KB
All 30 languages 30.08 KB 10.61 KB 9.56 KB

On 80 real JavaScript/TypeScript/JSX/TSX TanStack docs fixtures repeated across 5,040 blocks, using the median of three runs after warmup:

Bundle gzip Highlight time Generated HTML
TanStack Highlight 4.11 KB 78 ms 6.7 MiB
Sugar High 1.2.1 3.28 KB 377 ms 44.8 MiB

On all 334 committed docs fixtures, warmed highlighting took 4.6 ms with TanStack Highlight and 182 ms with Shiki 4.3.1. Shiki also took 22 ms to initialize and 47 ms to load languages, and produced 3.4x more HTML. The comparison falls back to plaintext for EJS, ENV, and TSRX in Shiki. These local measurements do not imply equivalent grammar depth.

Reproduce them with:

pnpm run size
pnpm run bench
pnpm run compare:sugar-high
pnpm run compare:shiki

Non-Goals

  • Automatic language detection
  • Editor or incremental parsing
  • Semantic language-service tokens
  • TextMate or VS Code theme compatibility
  • Hundreds of languages
  • Exact parity with language compilers or IDEs

The quality bar is common, valid code found in blogs and documentation. Every parser fix should add a focused regression fixture without turning the package into a general grammar runtime.

Development

pnpm install
pnpm run verify

verify checks types, package exports, publint, 334 real docs fixtures, focused parser regressions, bundle budgets, and a roughly 10,000-block throughput budget.

About

Tiny, synchronous syntax highlighting for blogs and documentation

Resources

Contributing

Stars

74 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages