A tiny, fast, simple syntax highlighter for the web and the terminal in JavaScript
- Tiny (~1.5 kB gzipped core, ~1 kB gzipped per language)
- Fast (generally outperforms Prism and highlight.js, see the benchmark)
- Simple (zero dependencies)
- Quick start
- How it works
- Comparison
- Web usage
- Terminal usage
- API
- Languages
- Themes
- Custom languages
- Custom themes
- Migrating from v1
- Benchmark
npm i @speed-highlight/coreIn a terminal, print the highlighted string:
import{highlightANSI}from'@speed-highlight/core';importthemefrom'@speed-highlight/core/themes/default.js';console.log(awaithighlightANSI('console.log("hello")','js',theme));In a component, highlight a string and render it:
import{useEffect,useState}from'react';import{highlightHTML}from'@speed-highlight/core';import'@speed-highlight/core/themes/default.css';exportfunctionCode({ code, lang }){const[html,setHtml]=useState('');useEffect(()=>{// highlightHTML is async (languages load on first use), skip stale resultsletstale=false;highlightHTML(code,lang).then(result=>{if(!stale)setHtml(result);});return()=>{stale=true;};},[code,lang]);return<divclassName={`shj-lang-${lang} shj-block`}dangerouslySetInnerHTML={{__html: html}}/>;}The tokenizer runs a language's regex rules over your code and emits typed tokens (kwd, str, cmnt, ...). On the web each token becomes a <span class="shj-syn-kwd">; in the terminal it becomes an ANSI escape code. A theme is just CSS (or a token-to-escape map) coloring those names, which is why themes are under 1 kB and writing your own is a few lines.
Highlighters trade size for grammar fidelity: TextMate engines (Shiki, starry-night) are the most faithful and heaviest, mature regex engines (highlight.js, Prism) sit in the middle, lightweight regex tokenizers (speed-highlight, sugar-high) are the smallest and approximate on exotic syntax.
| Core (gzip) | Per language (gzip) | Languages | Grammar model | Terminal | Status | Choose it for | |
|---|---|---|---|---|---|---|---|
| speed-highlight | 1.5 kB | 0.07–1.6 kB | ~30 | lightweight regex | ✅ built in | ✅ v2 (this repo) | runtime highlighting where size and startup matter |
| sugar-high | 1.7 kB | 0.18–2.9 kB | 25 | lightweight regex | ❌ | ✅ v2.0.0 | CSS-variable theming, JSX/TSX-aware JavaScript |
| Prism | 3.1 kB | 0.3–3 kB | ~290 | mature regex | ❌ | its plugin ecosystem | |
| highlight.js | 8.3 kB | 0.3–2.5 kB | ~190 | mature regex | ❌ (via wrappers) | ✅ v11.11 (Jun 2026) | broad auto-detection and rare languages |
| Shiki | 35 kB + engine: 145 kB WASM or 20 kB JS | 5–16 kB | ~220 | TextMate (VS Code) | ✅ via @shikijs/cli | ✅ v4.4, very active | highest fidelity (the same grammars as VS Code); zero client JS when run at build time |
| starry-night | 185 kB incl. WASM | 3–25 kB | 600+ | TextMate (GitHub) | ❌ | ✅ active | GitHub-identical rendering in Node |
Sizes are min+gzip, measured from the installed packages at the versions shown (per-language = range over the benchmark corpus; starry-night figures from its own README). Wrappers reuse these engines and inherit their numbers: lowlight/refractor wrap highlight.js/Prism for virtual DOMs, rehype-pretty-code and bright wrap Shiki, and the terminal-only emphasize and cli-highlight wrap highlight.js grammars into ANSI. Editors (CodeMirror, Monaco, tree-sitter) are a different category.
If you highlight at build time and bytes do not matter, use Shiki. speed-highlight's case is the opposite one: highlighting at runtime, where the entire library with all 34 grammars bundled into one file gzips to 9.0 kB, barely more than highlight.js's core alone, before it has loaded a single grammar.
Frameworks own their DOM, so highlight the string and render it, as in the quick start (mutating a mounted node with highlightElement gets wiped on the next render). The output is HTML-escaped (&, <, >), safe to inject even for untrusted code; the shj-lang-* and shj-block classes hook it into the theme. The same pattern works in Vue, Svelte, and Angular; ready-made components are in #85.
Mark code blocks with a shj-lang-* class and call highlightAll once:
<divclass="shj-lang-js">console.log('hello')</div><codeclass="shj-lang-js">inline code</code><scripttype="module">import{highlightAll}from'@speed-highlight/core';highlightAll();</script>Blocks are a single <div> instead of <pre><code> so the line-number gutter can be laid out inside; the shj-lang- prefix avoids colliding with Prism's language-* during a migration.
For per-element control use highlightElement. It renders a code element inline and anything else as a block, accepts block as an override, and sets data-lang so a theme can render a language header with content: attr(data-lang):
import{highlightElement}from'@speed-highlight/core';awaithighlightElement(element,'js',{showLineNumbers: true});Detection is a separate ~1 kB import so the core stays small. It recognizes about 20 common languages and returns 'plain' when unsure:
import{highlightElement}from'@speed-highlight/core';import{detectLanguage}from'@speed-highlight/core/detect';element.textContent=code;awaithighlightElement(element,detectLanguage(code));Languages load lazily through a loader. Replace it with setLoader to add custom languages or restrict what your bundler includes; a name the loader cannot resolve renders as plain text:
import{setLoader,defaultLoader}from'@speed-highlight/core';// add custom languages on top of the bundled onessetLoader(name=>customs[name]??defaultLoader(name));// or allow only the languages your bundler can code-splitsetLoader(name=>({js: ()=>import('@speed-highlight/core/languages/js.js'),css: ()=>import('@speed-highlight/core/languages/css.js'),})[name]?.());For full tree-shaking skip the loader entirely: tokenizeWith takes every language from the caller, so a bundler keeps only what you import. Include the sub-languages a grammar embeds (html uses css and js; js uses jsdoc, todo, and regex). A sub that is not given keeps the type of its rule and only skips the inner highlighting:
import{tokenizeWith}from'@speed-highlight/core/tokenize';import{html,css,js,jsdoc,todo,regex}from'@speed-highlight/core/languages';tokenizeWith(code,html,(str,type)=>{/* ... */},{languages: { css, js, jsdoc, todo, regex }});Note
highlightHTML and tokenizeWith never touch the DOM, so they also run server-side or in a web worker: highlight there and send the string over.
<linkrel="stylesheet" href="https://cdn.jsdelivr.net/npm/@speed-highlight/core@2/dist/themes/default.css"><scripttype="module">import{highlightAll}from'https://cdn.jsdelivr.net/npm/@speed-highlight/core@2/dist/index.js';highlightAll();</script>highlightANSI returns a string ready to print; the theme is required, import one from themes/*.js:
import{highlightANSI}from'@speed-highlight/core';importthemefrom'@speed-highlight/core/themes/atom-dark.js';console.log(awaithighlightANSI(code,'js',theme));A terminal theme is a plain token-to-escape map, built with the termcolor helpers or raw escapes:
import*ascolfrom'@speed-highlight/core/themes/termcolor.js';exportdefault{kwd: col.red,str: col.green,cmnt: col.gray,};For Deno, use the deno module:
import{highlightANSI}from'https://deno.land/x/speed_highlight_js/dist/index.js';importthemefrom'https://deno.land/x/speed_highlight_js/dist/themes/default.js';console.log(awaithighlightANSI('console.log("hello")','js',theme));The main entry covers most apps; reach for /tokenize when you want the raw token stream and full control over what gets bundled. Everything ships TypeScript types.
| Entry | Export | Description |
|---|---|---|
@speed-highlight/core | highlightAll(opt?) | Highlight every element with a shj-lang-* class |
highlightElement(elm, lang?, opt?) | Highlight one element (language read from its class by default) | |
highlightHTML(src, lang, opt?) | Highlight a string, resolves to an HTML string | |
highlightANSI(src, lang, theme) | Highlight a string, resolves to an ANSI string for terminals | |
tokenize(src, lang, onToken) | Loader-based tokenizer, calls onToken(text, type) | |
setLoader(loader) / defaultLoader | Replace or compose how language names are resolved | |
.../detect | detectLanguage(code) | Guess the language, 'plain' when unsure |
.../tokenize | tokenizeWith(src, lang, onToken, opt?), tokenizer | Registry-free synchronous tokenizer (and the underlying generator), languages passed by the caller |
.../languages | one named export per language | Grammars, import only what you need |
.../themes/*.css | Web themes | |
.../themes/*.js | Terminal themes, plus termcolor.js helpers |
lang is a name ('js') or a grammar object passed directly. opt is { block?: boolean, showLineNumbers?: boolean }: line numbers are opt-in, block defaults to true, except that highlightElement and highlightAll read it off the element instead, where a code element is inline and anything else is a block.
| Name | CSS Class | Support | Detection | Size (gzip, 14.2 kB total) |
|---|---|---|---|---|
| Assembly | shj-lang-asm | ✅ | 194 B | |
| Bash | shj-lang-bash | ✅ | 430 B | |
| Brainfuck | shj-lang-bf | increment, operator, print, comment | ❌ | 137 B |
| C | shj-lang-c | ✅ | 429 B | |
| CSS | shj-lang-css | comment, str, selector, units, function, ... | ✅ | 343 B |
| CSV | shj-lang-csv | punctuation, ... | ❌ | 96 B |
| Diff | shj-lang-diff | ✅ | 144 B | |
| Dockerfile | shj-lang-docker | ✅ | 566 B | |
| Git | shj-lang-git | comment, insert, deleted, string, ... | ❌ | 222 B |
| Go | shj-lang-go | ✅ | 329 B | |
| HTML | shj-lang-html | ✅ | 627 B | |
| HTTP | shj-lang-http | keywork, string, punctuation, variable, version | ✅ | 986 B |
| INI | shj-lang-ini | ❌ | 158 B | |
| Java | shj-lang-java | ✅ | 457 B | |
| JavaScript | shj-lang-js | basic syntax, regex, jsdoc, json, template literals | ⛔ reported as TypeScript | 758 B |
| JSDoc | shj-lang-jsdoc | ❌ | 247 B | |
| JSON | shj-lang-json | string, number, bool, ... | ❌ | 172 B |
| LeanPub Markdown | shj-lang-leanpub-md | ❌ | 1.2 kB | |
| Log | shj-lang-log | number, string, comment, errors | ❌ | 223 B |
| Lua | shj-lang-lua | ✅ | 273 B | |
| Makefile | shj-lang-make | ✅ | 223 B | |
| Markdown | shj-lang-md | ✅ | 1.1 kB | |
| Perl | shj-lang-pl | ✅ | 329 B | |
| Plain text | shj-lang-plain | ❌ | 71 B | |
| Python | shj-lang-py | ✅ | 416 B | |
| Regex | shj-lang-regex | count, set, ... | ❌ | 172 B |
| Rust | shj-lang-rs | ✅ | 414 B | |
| SQL | shj-lang-sql | number, string, function, ... | ✅ | 1.7 kB |
| TODO | shj-lang-todo | ❌ | 185 B | |
| TOML | shj-lang-toml | comment, table, string, bool, variable | ❌ | 236 B |
| TypeScript | shj-lang-ts | js syntax, ts keyword, types | ✅ | 849 B |
| URI | shj-lang-uri | ✅ | 176 B | |
| XML | shj-lang-xml | ✅ | 511 B | |
| YAML | shj-lang-yaml | comment, numbers, variable, string, bool | ✅ | 208 B |
| Name | Terminal (gzip) | Web (gzip) |
|---|---|---|
default | ✅ 174 B | ✅ 603 B |
atom-dark | ✅ 174 B | ✅ 699 B |
dark | ❌ | ✅ 695 B |
github-dark | ❌ | ✅ 691 B |
github-dim | ❌ | ✅ 700 B |
github-light | ❌ | ✅ 672 B |
visual-studio-dark | ❌ | ✅ 694 B |
A language is an array of rules. Every rule's regex (global flag required) is tried; the earliest match in the string wins, ties go to the earlier rule:
exportdefault[{match: /\/\/.*/g,type: 'cmnt'},{expand: 'str'},{expand: 'num'},{match: /\b(if|else|for|while|return)\b/g,type: 'kwd'},];{ match, type }tags what the regex matches with a token type{ expand }reuses a shared pattern:'num','str', or'strDouble'{ match, sub }re-tokenizes the matched region with another language: a name (loaded through the loader), an inline grammar array, or a functioncode => name | grammardeciding per match
A language can also set a default token for unmatched text by exporting { type, sub } instead of a bare array (see http.js). Use a grammar by passing it directly as lang, or register a name with setLoader. To extend an existing language, spread it after your rules:
importjsfrom'@speed-highlight/core/languages/js.js';exportdefault[{match: /\b(signal|effect)\b/g,type: 'func'},
...js,];Token types:
| Token | Used for | Token | Used for | Token | Used for | ||
|---|---|---|---|---|---|---|---|
kwd | keywords | type | types | esc | escape sequences | ||
str | strings | class | classes | section | section delimiters | ||
num | numbers | var | variables | insert | inserted parts (diff) | ||
cmnt | comments | oper | operators | deleted | deleted parts (diff) | ||
func | functions | bool | booleans | err | errors |
Missing a language? Open an issue or send a PR adding a file to src/languages/.
A web theme colors the token classes; start from default.css and override:
[class*="shj-lang-"] { color:#f8f8f2; background:#282a36; }
.shj-syn-kwd { color:#ff79c6; }
.shj-syn-str, .shj-syn-insert { color:#50fa7b; }
.shj-syn-cmnt { color:#6272a4; font-style: italic; }
.shj-numbers { color:#6272a4; }Display-mode hooks: .shj-inline (inside code), .shj-block, and .shj-numbers for the gutter. Terminal themes are the token-to-escape maps shown in Terminal usage.
| v1 | v2 |
|---|---|
highlightText(src, lang) | highlightHTML(src, lang) |
printHighlight(src, lang) from /terminal | console.log(await highlightANSI(src, lang, theme)) |
setTheme('atom-dark') | pass the theme: highlightANSI(src, lang, theme) |
loadLanguage(name, grammar) | setLoader(...) or pass the grammar directly as lang |
@speed-highlight/core/terminal entry | merged into @speed-highlight/core |
common.js shared patterns | { expand: 'num' | 'str' | 'strDouble' } built into the tokenizer |
{ hideLineNumbers: true } | now the default, line numbers are opt-in with { showLineNumbers: true } |
oneline display mode | removed, a div is always a block |
highlightElement(elm, lang, mode, opt) | the mode moved into the options: highlightElement(elm, lang, { block }) |
shj-multiline class | shj-block |
$ npmrunbenchmarknodev26.7.0,darwinarm64,AppleM4corpus: js,css,json,md,sql,py,bash,tiledto3sizes(tiny(1KB) / medium(16KB) / huge(128KB)),medianof9trialsperlanguage,averagedacrossthecorpustiny(1KB)medium(16KB)huge(128KB)speed-highlight1,144,010ops/min74,720ops/min8,583ops/minprismjs655,152ops/min34,310ops/min2,575ops/minhighlight.js595,113ops/min48,666ops/min5,473ops/minsugar-high238,406ops/min13,405ops/min1,376ops/minshiki(jsengine)89,925ops/min6,682ops/min841ops/mincoldstart(import + firsthighlightoftest.js):
speed-highlight7.3msprismjs8.6mshighlight.js19mssugar-high18msshiki(jsengine)169msspeed-highlightperlanguage(warm,medianof9trials):
tiny(1KB)medium(16KB)huge(128KB)js666,642ops/min40,773ops/min4,704ops/mincss1,006,895ops/min62,774ops/min7,284ops/minjson1,964,840ops/min127,890ops/min15,057ops/minmd1,080,739ops/min86,883ops/min10,453ops/minsql1,369,247ops/min88,513ops/min10,180ops/minpy974,179ops/min56,165ops/min6,260ops/minbash945,526ops/min60,045ops/min6,141ops/minIdentical inputs from examples/languages/, tiled up to each size bucket, HTML-string output for every library, one op = one highlighted file. Each figure is the median of 9 repeated trials, reported as ops/min (the huge bucket can drop below 1 op/sec for the slower libraries). Warm runs have grammars preloaded; cold start is import plus first highlight, measured once (not size-swept). Shiki does more work by design (see Comparison).
