What does Shiki actually emit when you pass two themes?
Shiki's dual-theme mode does not write a colour. With defaultColor: false it writes two custom properties onto every token and leaves color unset, so a code block is only highlighted if some rule in your stylesheet reads those properties back. Nothing about that arrangement is enforced anywhere: the HTML is valid, the build passes, and the failure mode is flat text that looks like a deliberate monochrome choice.
I shipped that failure on this site for weeks. The pipeline was configured correctly, every token carried the right hex values, and every code block rendered in the surrounding body colour in both themes, because the consumer half of the contract did not exist yet.
Shiki's dual-theme output is a pair of custom properties on every token and no colour, so without CSS that consumes them every token inherits the surrounding ink and the block renders flat — with no build error, no warning, and no failed check.
On this site the highlighting happens during static generation, in components/mdx/mdx-renderer.tsx, so the coloured HTML is part of the first byte the browser receives. The plugin configuration is small; reduced to its essentials, it types like this:
import rehypeShiki, { type RehypeShikiOptions } from '@shikijs/rehype';
import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
// `defaultColor: false` is the contract: Shiki emits no inline `color` on any
// token, only the two variables below, which nothing in this file consumes.
const shiki: RehypeShikiOptions = {
themes: { light: 'github-light', dark: 'github-dark-default' },
defaultColor: false,
addLanguageClass: true,
};
export const rehypePlugins = [rehypeSlug, [rehypeShiki, shiki]];
export const remarkPlugins = [remarkGfm];
Here is what that configuration actually puts in the HTML. Rather than retype it from memory, ask the built page — run this against the article you are reading:
# The declarations Shiki leaves on the tokens of one rendered page, deduplicated
# so the output does not depend on token order.
$ curl -s http://localhost:3000/blog/shiki-dual-theme-code-blocks \
| grep -o -- 'style="--shiki-light:#[^"]*"' | sort -u | head -3
style="--shiki-light:#005cc5;--shiki-dark:#79c0ff"
style="--shiki-light:#24292e;--shiki-dark:#e6edf3"
style="--shiki-light:#d73a49;--shiki-dark:#ff7b72"
Six hex values, zero colours. Each token is a <span> whose entire style attribute is two custom properties, and the block wrapper names both themes in its class list — shiki shiki-themes github-light github-dark-default — which is metadata, not styling. Nothing in that tree declares color, so as far as CSS is concerned those spans are ordinary text. A token carrying --shiki-light:#d73a49;--shiki-dark:#ff7b72 is 43 bytes of attribute, which is the price of not shipping two copies of the markup: a 40-line block of roughly 350 tokens carries about 15 KB more HTML than a single-theme render. The values repeat heavily, so compression claws most of that back, but the raw bytes are real and worth measuring if you have a strict budget.
Why does nothing error when the consumer is missing?
Because every individual artifact involved is legal. An inline custom property that nobody reads is valid CSS. A <span> with no colour declaration is valid HTML. color is inherited, so those tokens take the ink colour of the surrounding article and read as intentional, in both themes.
The failure survives every gate I normally rely on. TypeScript never sees a stylesheet. next build produces byte-identical output whether or not the consumer rules exist. Linters do not flag unused custom properties, because a property set inline on an element is indistinguishable from one set by a rule three files away. Contrast tooling is happier than it should be, since flat near-black text on a near-white panel passes AA by a wide margin. Only a human looking at the rendered page can see it, and a human who did not write the article cannot tell flat monospace from a minimalist design decision.
There is a second-order version of the same bug that is easier to misdiagnose. If a rule exists but references a variable that is not there — color: var(--shiki-dark) in a project configured with a single theme — the declaration becomes invalid at computed-value time and the property falls back to inherited as well. The block still renders, still does not error, and now differs between your two theme branches for no visible reason.
None of the variants announce themselves, and they are distinguishable only by looking:
| What the reader sees | What Shiki emitted | What your CSS consumed | Fails the build |
|---|---|---|---|
| Flat text, both themes | Two variables per token, no color | Nothing | No |
| Correct in light, grey in dark | Inline light color plus both variables | Only the --shiki-light branch | No |
| Right palette, no italics | --shiki-*-font-style on emphasised tokens | Only color | No |
| White panel on a dark canvas | A theme background | Only color | No |
| Both branches identical | Correct variables | A variable name that was never emitted | No |
How do you consume the properties without a specificity fight?
Map both branches in plain CSS, with the selectors matched to how your theme is actually applied. On this site the default is dark and light is signalled by a light class on <html>, set by an inline script in the root layout before first paint, so the two branches are deliberately asymmetric:
/* Light is a class; dark is the absence of it. */
.light .prose-geek .shiki,
.light .prose-geek .shiki span {
color: color-mix(in srgb, var(--shiki-light) 78%, var(--code-contrast));
}
:root:not(.light) .prose-geek .shiki,
:root:not(.light) .prose-geek .shiki span {
color: color-mix(in srgb, var(--shiki-dark) 78%, var(--code-contrast));
}
/* Shiki can also emit a theme background. The panel owns that instead, so a
code block sits on the site's raised canvas rather than on GitHub's white. */
.prose-geek .shiki {
background-color: transparent !important;
}
Note the absence of !important on colour. Almost every dual-theme example on the web includes it, which is correct for the default configuration: with defaultColor: 'light', Shiki writes color:#24292e inline on the token, and only !important beats an inline declaration. Once you set defaultColor: false there is no inline colour to beat, and carrying the !important forward is a trap, because it also outranks the overrides you will want later. A @media print rule that forces the light palette onto paper will lose to it silently, and you will be debugging the same class of invisible failure again.
The color-mix is not decoration. Measured on this site's canvas, github-light's comment grey lands around 4.3:1 and its keyword red around 4.5:1 at 13px code size — at or just under the 4.5:1 AA threshold, which is the worst kind of miss because it is measurable but not visible. Mixing each token 22% toward a contrast token derived from the theme foreground lifts the whole palette clear of the threshold while keeping the hue relationships: comments still read as comments. If your canvas token changes, re-measure, because the ratio moves with it.
The defaultColor setting decides how much CSS you owe Shiki, and the three cases are not interchangeable:
defaultColor | Inline on each token | What your CSS must do | !important on colour |
|---|---|---|---|
'light' (the default) | color:#24292e plus both variables | Override dark with var(--shiki-dark) | Required — inline declaration wins otherwise |
'dark' | color:#e6edf3 plus both variables | Override light with var(--shiki-light) | Required, same reason |
false | Both variables, no color | Map both branches itself | Not needed, and harmful to print overrides |
It is also worth knowing which parts of a theme are not colour, because they fail separately. Shiki emits --shiki-light-font-style and --shiki-dark-font-style on italic tokens. A consumer that maps only color drops italic comments and keywords from both themes, and nothing tells you. If you care about the full style, consume the font-style, font-weight and text-decoration variables too, or accept a palette that is correct in colour and wrong in emphasis.
Where you put the rules matters as much as what they say. A typography plugin that styles pre code — Tailwind Typography's prose classes are the usual example — will set a colour on the <code> element, and the tokens inside inherit it. Your dual-theme rules have to be at least as specific as that rule and target the token spans, not just the <pre>. This is why the selectors above repeat the article wrapper class and list .shiki span explicitly: one rule for the container leaves the tokens inheriting the container's colour, which is the flat-text failure again, reached from the other direction.
When is dual-theme output the wrong choice?
When the site has one theme. The 43 bytes per token buy you nothing if the class on <html> never changes, and a single theme with defaultColor left at its default is both smaller and impossible to get wrong in this particular way.
When the output leaves CSS. Custom properties resolve inside a stylesheet and nowhere else. Open Graph image generation, canvas rendering, PDF pipelines that print your markup, and most email clients will show you uncoloured text no matter how correct your rules are, because the consumer half of the contract cannot run there. For those targets, pre-render one theme's colours inline and skip the variables.
When you highlight in the browser rather than at build time. Client-side highlighting means shipping the grammar and the highlighter, and it puts a frame of unhighlighted code in front of the reader before the colours arrive. Whether the highlighted HTML is in the first byte is not a Shiki question at all; it follows from how the route is rendered, and it is worth settling the tradeoffs between static, dynamic, streamed and client rendering in the App Router before choosing where the highlighter runs.
And when the two palettes are not contrast-matched to your canvas. Dual-theme support makes it trivial to serve two themes, which also makes it trivial to serve one accessible palette and one that is not. The mechanism is indifferent to that outcome.
One more limit, since it is easy to assume otherwise: consuming the variables is not the same as controlling the highlighting. If a token arrives with no variables at all — an unsupported language falling back to plain text, or a diff snippet where the diff grammar emits its own classes — there is nothing for your CSS to resolve, and no amount of specificity recovers it. Check the emitted markup for the languages you actually publish rather than the one you tested with.
What should you check before the next deploy?
Count the emitters and the consumers. Fetch a rendered article and count the dual-theme properties in the markup, then count the var() references that read them back, and compare the two numbers:
# Emitters: how many tokens carry the dual-theme variables?
emitters=$(curl -s http://localhost:3000/blog/geo-for-engineers \
| grep -o -- '--shiki-dark:#[0-9a-fA-F]\{3,8\}' | wc -l | tr -d ' ')
# Consumers: how many declarations read them back?
consumers=$(grep -c -- 'var(--shiki-dark)' app/globals.css)
echo "emitters=${emitters} consumers=${consumers}"
if [ "$emitters" -gt 0 ] && [ "$consumers" -eq 0 ]; then
echo "silent failure: variables are emitted and never consumed"
fi
A large first number with a zero second number is the bug, and it takes about fourteen lines of CSS to close. After that, check both themes rather than one, including the case where prefers-color-scheme disagrees with your stored preference, then check what a print stylesheet does with a !important colour rule. The reason I now treat this as a rendering contract instead of a styling detail is that every other failure in a build announces itself, while this one ships looking finished in both themes, and the only authority on whether the highlighting exists is the CSS that consumes it.
Keep reading
- A Streaming UI for Long-Running Tasks Must Report Job State, Not Elapsed Time2026-02-228 minEngineering
- GEO for Engineers: Making Your Site Legible to Language Models2026-03-216 minEngineering
- Static by Default: Next.js App Router Rendering Strategies2026-03-159 minEngineering
- hreflang on a Bilingual Site Is Three Invariants: One URL Per Document, Absolute Values, Real Alternates2026-02-078 minEngineering