Syntax highlighting
The Code block is presentation-only — it owns the well surface, the header band, the copy button, and the code typography, but not the colours. Shiki produces the tokens; the Code block frames them. So highlighting is a caller's choice of when to run Shiki, and there are exactly two: at build time for static content, at runtime for dynamic content. Both feed a tokenised <pre> into <CodeBlock>, both emit the same theme-variable markup, and both are driven by one stylesheet.
Build time or runtime
Pick the mode by asking where the code comes from.
| Mode | For code that is… | Runs Shiki | Cost |
|---|---|---|---|
| Build time | known when you build — MDX docs, changelogs, static examples | once, in the bundler (@shikijs/rehype) | zero client JS |
| Runtime | only known in the browser — API responses, webhook payloads, user input | in the client, on demand | a lazily-loaded highlighter |
They are not mutually exclusive — a docs site highlights its prose fences at build time and a live "try it" panel at runtime — and because both are configured the same way, they share one stylesheet.
The one setting that makes theming work
Build the highlighter with a light + dark theme pair and defaultColor: false. With that flag, Shiki does not bake a color onto each token — it emits two custom properties instead, --shiki-light and --shiki-dark:
<span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">true</span>Now a single flag on the document root picks the palette in CSS, with no re-highlighting when the theme changes:
/* Default to the dark token; the light palette wins under the data-theme flag. */
.shiki,
.shiki span {
color: var(--shiki-dark);
}
:root[data-theme="light"] .shiki,
:root[data-theme="light"] .shiki span {
color: var(--shiki-light);
}
/* Shiki also stamps a theme background on the <pre>; drop it so the Code block body shows through. */
.shiki {
margin: 0;
background-color: transparent;
}The Noctis theme engine bakes light/dark into the token values but writes no DOM flag, so mirror its resolved mode onto <html> as data-theme — one small client component, the same one the docs site uses:
"use client";
import { isBright, parseColor } from "@stridge/noctis/theme";
import { useTheme } from "@stridge/noctis/tokens/react";
import { useInsertionEffect } from "react";
/** Mirror the engine's resolved light/dark mode onto <html data-theme> so the Shiki CSS can pick a palette. */
export function ThemeModeSync() {
const { input } = useTheme();
useInsertionEffect(() => {
const bright =
input.mode === "light" ? true : input.mode === "dark" ? false : isBright(parseColor(input.background));
document.documentElement.dataset.theme = bright ? "light" : "dark";
}, [input]);
return null;
}Build time — @shikijs/rehype
For MDX and other static content, tokenise in the bundler so the browser ships coloured markup and no highlighter. Add @shikijs/rehype to the MDX pipeline with the theme pair and defaultColor: false:
// next.config.ts
import createMDX from "@next/mdx";
import rehypeShiki from "@shikijs/rehype";
const withMDX = createMDX({
options: {
rehypePlugins: [
[
rehypeShiki,
{
themes: { light: "github-light", dark: "github-dark-dimmed" },
defaultColor: false,
},
],
],
},
});Shiki rebuilds each fence into a <pre class="shiki">…</pre>. Render that inside a <CodeBlock> by mapping MDX's pre element — the frame, header, and copy button come from Noctis, the tokens from Shiki:
// mdx-components.tsx
import { CodeBlock } from "@stridge/noctis/code-block";
export const mdxComponents = {
pre: ({ children, ...props }) => (
<CodeBlock language={props["data-language"]}>
<pre {...props}>{children}</pre>
</CodeBlock>
),
};Runtime — a lazily-loaded Shiki core
When the code is only known in the browser — an API response, a webhook payload, a value the user typed — highlight on the client. Two rules keep it cheap and correct: load Shiki lazily so it never weighs down the initial bundle, and highlight in an effect so the first render matches the server's and there is no hydration mismatch.
Create one shared highlighter with the fine-grained core — only the languages and themes you actually render, and the JavaScript regex engine (no Oniguruma WASM). Reaching it through dynamic import() keeps every byte of it out of the initial bundle:
// lib/highlighter.ts
import type { HighlighterCore } from "shiki/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
let highlighter: Promise<HighlighterCore> | undefined;
/** One shared highlighter, created on first use — extend the langs list as you need more. */
function loadHighlighter() {
highlighter ??= createHighlighterCore({
themes: [import("shiki/themes/github-light.mjs"), import("shiki/themes/github-dark-dimmed.mjs")],
langs: [import("shiki/langs/json.mjs"), import("shiki/langs/tsx.mjs"), import("shiki/langs/bash.mjs")],
engine: createJavaScriptRegexEngine(),
});
return highlighter;
}
export async function highlight(code: string, lang: string) {
const hl = await loadHighlighter();
return hl.codeToHtml(code, {
lang,
themes: { light: "github-light", dark: "github-dark-dimmed" },
defaultColor: false,
});
}Then a small client component tokenises after mount and drops the markup into <CodeBlock>, showing the raw code until Shiki is ready:
"use client";
import { CodeBlock } from "@stridge/noctis/code-block";
import { useEffect, useState } from "react";
import { highlight } from "./lib/highlighter";
export function HighlightedCode({ code, language }: { code: string; language: string }) {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let active = true;
highlight(code, language).then((next) => {
if (active) setHtml(next);
});
return () => {
active = false;
};
}, [code, language]);
return (
<CodeBlock language={language} copyText={code}>
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : <pre>{code}</pre>}
</CodeBlock>
);
}Because it highlights in an effect rather than during render, the server and the first client render both show the plain <pre>{code}</pre> — identical markup, no hydration mismatch — and the block upgrades to coloured tokens in place a beat later. Keep copyText={code} so the copy button always writes the exact source, independent of what is currently painted.
Line numbers
Both modes emit <span class="line"> per row, so a line-number gutter is pure CSS — no change to the highlighter. Gate it on the data-codeblock hook the Code block stamps on its frame, so a bare Shiki <pre> elsewhere stays un-numbered:
[data-codeblock] .shiki {
counter-reset: code-line;
}
[data-codeblock] .shiki .line {
counter-increment: code-line;
}
[data-codeblock] .shiki .line::before {
content: counter(code-line);
/* Sticky-pin the digits to the start edge and paint the body's own surface so scrolled tokens
never bleed under the gutter. */
position: sticky;
inset-inline-start: 0;
display: inline-block;
width: 1.75rem;
padding-inline-end: 0.75rem;
text-align: end;
color: var(--noctis-color-subtle);
background-color: var(--noctis-color-surface);
user-select: none;
font-variant-numeric: tabular-nums;
}The numbers render through ::before, so they are never part of the copied text.
See also
- Code block — the component this feeds: its anatomy, tabs, tokens, and API.
- Theme engine — where the resolved light/dark mode
ThemeModeSyncmirrors comes from. - Layers — the surface a Code block sits on, and how its body reads against the header.