Shiki: Paint Your Code Exactly Like VS Code
If you have ever pasted a snippet onto your blog and winced because the colors looked nothing like your editor, Shiki is the cure. It is a syntax highlighter built around a deceptively simple idea: instead of inventing simplified grammars, just borrow the exact same machinery that Visual Studio Code uses — TextMate grammars and VS Code theme files — and run code through it. The result is output that is essentially pixel-identical to what you see in your editor, including the awkward edge cases that regex-based highlighters love to mangle.
The other half of Shiki's magic is when it runs. Rather than shipping a highlighting engine to the browser and tokenizing on the client (the Prism and highlight.js approach), Shiki does the work ahead of time — at build time, on the server, or inside a React Server Component — and emits plain <pre><code> markup with inline styles. The browser downloads pre-colored HTML and zero JavaScript for highlighting. No hydration, no flash of unstyled code, no runtime cost. That makes it the default highlighter behind tools like VitePress, Astro, and Nuxt Content.
Why Shiki Stands Out
- Editor-grade accuracy. Shiki uses the real TextMate grammars VS Code ships, so template literals, JSX, TypeScript generics, and embedded languages (CSS-in-JS, SQL in strings) tokenize correctly. As the editor's grammars improve, so does your highlighting.
- True VS Code fidelity. It bundles around 60 real themes — Nord, GitHub Dark/Light, Vitesse, Dracula, Catppuccin, One Dark Pro and more — so your code matches your editor exactly.
- Zero client runtime. Highlight once at build or SSR time and ship static HTML. Perfect for docs, blogs, and marketing sites.
- Dual light/dark themes with pure-CSS switching and no re-highlighting.
- Flexible output. Get an HTML string, a HAST tree for framework-native rendering, or a raw token array for fully custom output.
- Transformers for diffs, line and word highlighting, focus blocks, and more.
Getting Shiki Into Your Project
Shiki is a single package with no peer dependencies to wrangle.
npm install shiki
yarn add shiki
That gives you the full bundle, which includes every grammar and theme. It is wonderfully convenient for build-time use; later we will look at the fine-grained shiki/core bundle when you need to keep things lean.
The One-Liner That Does Everything
The fastest way to highlight code is the codeToHtml shorthand. It lazily loads the grammar and theme you ask for, then hands back a ready-to-render HTML string.
import { codeToHtml } from 'shiki'
const html = await codeToHtml('const a = 1', {
lang: 'javascript',
theme: 'vitesse-dark',
})
Note the await — Shiki loads grammars, themes, and (by default) a WebAssembly regex engine asynchronously, so the shorthands are promises. The returned string is a complete <pre class="shiki">...</pre> block with inline color styles baked into every token.
If you want more than a string, two sibling shorthands open up the pipeline. codeToHast returns a standard HAST (hypertext abstract syntax tree), which is how Shiki plugs into the unified and rehype ecosystems and any framework that can render a tree. codeToTokens returns the raw token data so you can build whatever output you like.
import { codeToTokens, codeToHast } from 'shiki'
const { tokens } = await codeToTokens('<div class="foo">bar</div>', {
lang: 'html',
theme: 'min-dark',
})
const hast = await codeToHast('.text-red { color: red; }', {
lang: 'css',
theme: 'catppuccin-mocha',
})
Reusing a Highlighter Instance
The shorthands are convenient, but each call has to make sure the right grammar and theme are loaded. For anything beyond a one-off, create a highlighter instance once and reuse it. Creating one is expensive, so treat it as a long-lived singleton — never spin one up per render or per request.
import { createHighlighter } from 'shiki'
const highlighter = await createHighlighter({
themes: ['nord'],
langs: ['javascript'],
})
const code = highlighter.codeToHtml('const a = 1', {
lang: 'javascript',
theme: 'nord',
})
await highlighter.loadTheme('vitesse-light')
await highlighter.loadLanguage('css')
You preload the themes and languages you know you need, and you can lazily pull in more later with loadTheme and loadLanguage. Once a grammar or theme is loaded, subsequent codeToHtml calls against that instance are synchronous.
Highlighting Code in React
Shiki ships no opinionated React component, which is a feature, not an omission — you consume its output in whichever way fits your rendering model. The most common pattern for static content is to highlight at build or server time and drop the resulting HTML into the DOM.
import { codeToHtml } from 'shiki'
async function CodeBlock({ code }: { code: string }) {
const html = await codeToHtml(code, {
lang: 'tsx',
theme: 'github-dark',
})
return <div dangerouslySetInnerHTML={{ __html: html }} />
}
Inside a React Server Component this async component is perfectly natural and ships no client JavaScript at all. If you would rather avoid dangerouslySetInnerHTML, take the HAST route and convert it into real React elements with hast-util-to-jsx-runtime.
import { codeToHast } from 'shiki'
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
const hast = await codeToHast(code, { lang: 'tsx', theme: 'github-dark' })
const element = toJsxRuntime(hast, { Fragment, jsx, jsxs })
For Markdown and MDX pipelines, the @shikijs/rehype plugin slots straight into a unified chain and highlights every fenced code block for you, so authors keep writing plain Markdown while readers get editor-grade output.
Dual Themes Without a Drop of JavaScript
This is the feature people fall in love with. Swap the singular theme for a themes map, and Shiki emits both color sets at once using CSS variables.
const code = await codeToHtml('console.log("hello")', {
lang: 'javascript',
themes: { light: 'min-light', dark: 'nord' },
})
Each token now carries its light color inline and its dark color in a --shiki-dark variable:
<span style="color:#1976D2;--shiki-dark:#D8DEE9">console</span>
Switching themes is then pure CSS — no re-highlighting, no extra JavaScript, no layout shift. Wire it to the user's system preference, a dark class on <html>, or both.
@media (prefers-color-scheme: dark) {
.shiki span { color: var(--shiki-dark) !important; }
}
html.dark .shiki span { color: var(--shiki-dark) !important; }
Need more than two? Pass arbitrary keys and a defaultColor to choose which one renders inline. Setting defaultColor to 'light-dark()' even emits the modern CSS light-dark() function, letting the browser pick for you.
const code = await codeToHtml(source, {
lang: 'ts',
themes: { light: 'github-light', dark: 'github-dark', dim: 'github-dimmed' },
defaultColor: 'light',
})
Transformers: Diffs, Highlights, and Focus
Transformers hook into the HAST tree as Shiki builds it and let you annotate the output — diff markers, highlighted lines, dimmed focus blocks, and more. The @shikijs/transformers package collects the official ones. One important rule: transformers only add classes. You supply the matching CSS, which keeps your visual styling entirely in your control.
import { transformerNotationDiff } from '@shikijs/transformers'
import { codeToHtml } from 'shiki'
const html = await codeToHtml(code, {
lang: 'ts',
theme: 'nord',
transformers: [transformerNotationDiff()],
})
With that transformer in place, special comments inside your code drive the annotations. A line ending in // [!code ++] is marked as an addition, // [!code --] as a removal, // [!code highlight] as highlighted, and // [!code focus] dims everything else. There is a whole family to choose from:
transformerNotationDiff— added and removed lines.transformerNotationHighlightandtransformerNotationWordHighlight— emphasize lines or specific words.transformerNotationFocus— spotlight a block by dimming the rest.transformerNotationErrorLevel— error, warning, and info markers.transformerMetaHighlightandtransformerMetaWordHighlight— drive highlighting from the code fence meta, like a{1,3-4}line range or a/Hello/word match.transformerStyleToClass— convert inline styles into shared classes to de-duplicate large outputs.
Several transformers accept a matchAlgorithm option ('v1' for the legacy behavior or 'v3' for the newer one), which controls how comment lines count toward your ranges. Once classes are emitted, a little CSS turns them into green diff backgrounds, highlighted rows, or faded focus regions.
Choosing Your Regex Engine
Under the hood, TextMate grammars rely on Oniguruma-flavored regular expressions, and Shiki offers two engines to run them. The default is a compiled Oniguruma engine shipped as a WebAssembly blob — maximum compatibility with every bundled language, at the cost of a sizable .wasm file and an async load.
import { createHighlighter } from 'shiki'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
const highlighter = await createHighlighter({
themes: ['nord'],
langs: ['javascript'],
engine: createJavaScriptRegexEngine(),
})
The JavaScript RegExp engine transpiles those Oniguruma patterns to native JS regex, so there is no WASM file at all — a smaller bundle, faster startup, and better browser performance. It requires Node 20+ or a modern browser, and most (though not strictly all) languages are supported, so check compatibility if you rely on obscure grammars. For client-side highlighting it is usually the right call.
Going Lean With the Core Bundle
The full shiki package is gloriously easy but heavy — it carries every grammar and theme, which is irrelevant at build time but matters if you ship highlighting to the browser. The biggest lever you have is the fine-grained shiki/core bundle, where you import only the languages, themes, and engine you actually use. This is also the path for restricted runtimes like Cloudflare Workers.
import js from '@shikijs/langs/javascript'
import nord from '@shikijs/themes/nord'
import { createHighlighterCore } from 'shiki/core'
import { loadWasm } from 'shiki/engine/oniguruma'
await loadWasm(import('shiki/onig.wasm'))
const highlighter = await createHighlighterCore({
themes: [nord],
langs: [js],
})
highlighter.codeToHtml('console.log("shiki");', { theme: 'nord', lang: 'js' })
Pair the core bundle with the JavaScript regex engine and you can drop the WASM import entirely, shrinking your client payload to just the grammars and themes you genuinely need. Keep that highlighter as a singleton, and for heavy client-side workloads consider running it in a Web Worker so the main thread stays responsive.
A Few Honest Tradeoffs
Shiki is not free of sharp edges, and knowing them up front saves headaches. Tokenizing with real TextMate grammars is heavier than a regex highlighter — roughly seven times slower than Prism per highlight, though still in the millisecond range and completely irrelevant when you run at build time. Initialization is async, which can feel awkward in strictly synchronous code; the getSingletonHighlighter pattern and the createHighlighterCoreSync route help once you preload your assets. And because transformers only add classes, the visual polish for diffs and highlights is on you to write. None of these matter for the common case of build-time highlighting, where Shiki simply produces gorgeous static HTML.
When Shiki Is the Right Tool
Reach for Shiki when you want code on your site to look exactly like VS Code, when you are rendering statically or on the server and want zero client-side JavaScript for highlighting, when you need breadth and accuracy across many languages including tricky embedded grammars, or when you want pure-CSS dual themes and an extensible HAST pipeline. It is the battle-tested default behind a large slice of the modern docs ecosystem for good reason.
If instead you must highlight in the browser at runtime, need the tiniest possible bundle, or only support a language or two, a lighter client-side option like Prism or Sugar High may serve you better. But for the docs site, blog, or MDX-driven product page where the code should simply look right with no runtime cost, Shiki is hard to beat — it brings your editor's exact colors to the web and asks the browser to do nothing more than display them.