speed-highlight

August 25, 2026 · View on GitHub

NPM Version NPM Downloads

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)

Playground

Screenshot

Quick start

npm i @speed-highlight/core

In 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 }} />;
}

How it works

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.

Comparison

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)LanguagesGrammar modelTerminalStatusChoose it for
speed-highlight1.5 kB0.07–1.6 kB~30lightweight regex✅ built in✅ v2 (this repo)runtime highlighting where size and startup matter
sugar-high1.7 kB0.18–2.9 kB25lightweight regex✅ v2.0.0CSS-variable theming, JSX/TSX-aware JavaScript
Prism3.1 kB0.3–3 kB~290mature regex⚠️ v1.30.0, frozen since Mar 2025 (v2 rewrite)its plugin ecosystem
highlight.js8.3 kB0.3–2.5 kB~190mature regex❌ (via wrappers)✅ v11.11 (Jun 2026)broad auto-detection and rare languages
Shiki35 kB + engine: 145 kB WASM or 20 kB JS5–16 kB~220TextMate (VS Code)✅ via @shikijs/cli✅ v4.4, very activehighest fidelity (the same grammars as VS Code); zero client JS when run at build time
starry-night185 kB incl. WASM3–25 kB600+TextMate (GitHub)✅ activeGitHub-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.

Web usage

In a component

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.

On a plain page

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 });

Detect the language

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));

Control loading and bundling

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.

CDN (no build step)

<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>

Terminal usage

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));

API

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.

EntryExportDescription
@speed-highlight/corehighlightAll(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) / defaultLoaderReplace or compose how language names are resolved
.../detectdetectLanguage(code)Guess the language, 'plain' when unsure
.../tokenizetokenizeWith(src, lang, onToken, opt?), tokenizerRegistry-free synchronous tokenizer (and the underlying generator), languages passed by the caller
.../languagesone named export per languageGrammars, import only what you need
.../themes/*.cssWeb themes
.../themes/*.jsTerminal 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.

Languages

NameCSS ClassSupportDetectionSize (gzip, 14.4 kB total)
Assemblyshj-lang-asm194 B
Bashshj-lang-bash430 B
Brainfuckshj-lang-bfincrement, operator, print, comment137 B
Cshj-lang-c429 B
CSSshj-lang-csscomment, str, selector, units, function, ...332 B
CSVshj-lang-csvpunctuation, ...96 B
Diffshj-lang-diff144 B
Dockerfileshj-lang-docker566 B
Gitshj-lang-gitcomment, insert, deleted, string, ...222 B
Goshj-lang-go329 B
HTMLshj-lang-htmlinline style and event handlers726 B
HTTPshj-lang-httpkeywork, string, punctuation, variable, version986 B
INIshj-lang-ini158 B
Javashj-lang-java457 B
JavaScriptshj-lang-jsbasic syntax, regex, jsdoc, json, template literals⛔ reported as TypeScript820 B
JSDocshj-lang-jsdoc251 B
JSONshj-lang-jsonstring, number, bool, ...172 B
LeanPub Markdownshj-lang-leanpub-md1.2 kB
Logshj-lang-lognumber, string, comment, errors223 B
Luashj-lang-lua273 B
Makefileshj-lang-make223 B
Markdownshj-lang-md1.1 kB
Perlshj-lang-pl329 B
Plain textshj-lang-plain71 B
Pythonshj-lang-py416 B
Regexshj-lang-regexcount, set, ...176 B
Rustshj-lang-rs414 B
SQLshj-lang-sqlnumber, string, function, ...1.7 kB
TODOshj-lang-todo185 B
TOMLshj-lang-tomlcomment, table, string, bool, variable236 B
TypeScriptshj-lang-tsjs syntax, ts keyword, types911 B
URIshj-lang-uri176 B
XMLshj-lang-xml511 B
YAMLshj-lang-yamlcomment, numbers, variable, string, bool208 B

Themes

NameTerminal (gzip)Web (gzip)
default174 B603 B
atom-dark174 B699 B
dark695 B
github-dark691 B
github-dim700 B
github-light672 B
visual-studio-dark694 B

Custom languages

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 function code => name | grammar deciding 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:

TokenUsed forTokenUsed forTokenUsed for
kwdkeywordstypetypesescescape sequences
strstringsclassclassessectionsection delimiters
numnumbersvarvariablesinsertinserted parts (diff)
cmntcommentsoperoperatorsdeleteddeleted parts (diff)
funcfunctionsboolbooleanserrerrors

Missing a language? Open an issue or send a PR adding a file to src/languages/.

Custom themes

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.

Migrating from v1

v1v2
highlightText(src, lang)highlightHTML(src, lang)
printHighlight(src, lang) from /terminalconsole.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 entrymerged 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 moderemoved, a div is always a block
highlightElement(elm, lang, mode, opt)the mode moved into the options: highlightElement(elm, lang, { block })
shj-multiline classshj-block

Benchmark

$ 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/min

Identical 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).