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';
import theme from '@speed-highlight/core/themes/default.js';
console.log(await highlightANSI('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';
export function Code({ code, lang }) {
const [html, setHtml] = useState('');
useEffect(() => {
// highlightHTML is async (languages load on first use), skip stale results
let stale = false;
highlightHTML(code, lang).then(result => { if (!stale) setHtml(result); });
return () => { stale = true; };
}, [code, lang]);
return <div className={`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:
<div class="shj-lang-js">console.log('hello')</div>
<code class="shj-lang-js">inline code</code>
<script type="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';
await highlightElement(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;
await highlightElement(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 ones
setLoader(name => customs[name] ?? defaultLoader(name));
// or allow only the languages your bundler can code-split
setLoader(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.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@speed-highlight/core@2/dist/themes/default.css">
<script type="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';
import theme from '@speed-highlight/core/themes/atom-dark.js';
console.log(await highlightANSI(code, 'js', theme));A terminal theme is a plain token-to-escape map, built with the termcolor helpers or raw escapes:
import * as col from '@speed-highlight/core/themes/termcolor.js';
export default {
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';
import theme from 'https://deno.land/x/speed_highlight_js/dist/themes/default.js';
console.log(await highlightANSI('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:
export default [
{ 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:
import js from '@speed-highlight/core/languages/js.js';
export default [
{ 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 |
$ npm run benchmark
node v26.7.0, darwin arm64, Apple M4
corpus: js, css, json, md, sql, py, bash, tiled to 3 sizes (tiny (1 KB) / medium (16 KB) / huge (128 KB)), median of 9 trials per language, averaged across the corpus
tiny (1 KB) medium (16 KB) huge (128 KB)
speed-highlight 1,144,010 ops/min 74,720 ops/min 8,583 ops/min
prismjs 655,152 ops/min 34,310 ops/min 2,575 ops/min
highlight.js 595,113 ops/min 48,666 ops/min 5,473 ops/min
sugar-high 238,406 ops/min 13,405 ops/min 1,376 ops/min
shiki (js engine) 89,925 ops/min 6,682 ops/min 841 ops/min
cold start (import + first highlight of test.js):
speed-highlight 7.3 ms
prismjs 8.6 ms
highlight.js 19 ms
sugar-high 18 ms
shiki (js engine) 169 ms
speed-highlight per language (warm, median of 9 trials):
tiny (1 KB) medium (16 KB) huge (128 KB)
js 666,642 ops/min 40,773 ops/min 4,704 ops/min
css 1,006,895 ops/min 62,774 ops/min 7,284 ops/min
json 1,964,840 ops/min 127,890 ops/min 15,057 ops/min
md 1,080,739 ops/min 86,883 ops/min 10,453 ops/min
sql 1,369,247 ops/min 88,513 ops/min 10,180 ops/min
py 974,179 ops/min 56,165 ops/min 6,260 ops/min
bash 945,526 ops/min 60,045 ops/min 6,141 ops/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).
