Skip to content

Repository files navigation

speed-highlight

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) 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 ⚠️ v1.30.0, frozen since Mar 2025 (v2 rewrite) 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.

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.

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.

Languages

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

Themes

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

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:

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

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

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

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

About

A tiny, fast, simple syntax highlighter for the web and the terminal in JavaScript

Topics

Resources

Stars

399 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages