Skip to main content

← Blog

Contrast-Aware Shiki Code Blocks on Dark Editorial Panels

Shiki themes inherit assumptions about their original backgrounds. Here is how I remapped GitHub Dark colors to semantic CSS variables, kept highlighting on the server, and measured the result on the site’s real navy panels.

Published
July 9, 2026
Updated
Updated July 13, 2026
Author
Daniel Mark
Reading time
9 min read

On my site, code blocks sit on dark navy panels inside otherwise light editorial pages. Shiki's github-dark theme looked fine at first, but syntax colors do not exist in isolation. Their contrast depends on the surface behind them.

GitHub Dark assigns #6a737d to comments. My code panels use --sidebar, which resolves to #0b1422 on the default light canvas. That combination measures 3.83:1. WCAG 2.2 AA requires 4.5:1 for normal-sized text, so comments fell below the target.

I did not want syntax colors to remain an accidental property of an upstream theme. I kept Shiki for tokenization, translated its theme colors into semantic CSS variables, and rendered the resulting tokens on the server. Shiki still identifies the code. The site decides how that code should look on its actual surfaces.

The rendering pipeline

Authors do not insert React components into MDX. They add a Code snippet block in Payload's Lexical editor. The block stores the filename, language, source, optional line annotations, and toggles for the language label and copy button.

When Next.js renders a post, the stored Lexical JSON moves through this path:

Alt text: Diagram showing a Payload code block flowing through the Lexical renderer and server-side Shiki highlighting into semantic CSS colors and React spans, with optional client components for annotations, copying, and tabs.

RichTextRenderer registers the code-snippet converter, parses the Payload fields, and renders CodeSnippet. The same server component also supports inline Lexical code nodes, tabbed examples, and statement blocks.

CodeSnippet preprocesses line-highlight markers, calls highlightCodeLines, and assembles a <figure> containing the code panel. It does not accept an HTML string from codeToHtml. The renderer consumes Shiki tokens and creates React spans itself, applying the final token color along the way.

The client boundary appears only when a snippet needs interaction:

  • Line annotations mount CodeSnippetInteractive and a Radix Dialog.
  • An editor-enabled copy control mounts CopyCodeButton.
  • Tabbed examples pass their server-rendered panels to CodeTabsClient.

A plain article snippet without annotations or a copy button adds no code-block-specific client JavaScript.

Published posts use static generation with a one-hour revalidation interval. Highlighting therefore runs as part of the server render that produces the cached page, rather than in each reader's browser. I have not benchmarked this against a client highlighter. The concrete benefit is architectural: Shiki, its grammars, and its theme data stay out of the client bundle.

Keeping Shiki on the server

The highlighting module starts with import "server-only", making the boundary explicit. The site currently uses Shiki 4.2 and creates a module-level highlighter promise:

1
highlighterPromise = createHighlighter({
2
themes: ["github-dark"],
3
langs: [/* 26 registered languages */],
4
});

The registered set runs from plain text and the usual web languages through Solidity, Rust, Go, and Python. Reusing the promise avoids constructing a highlighter for every snippet in the same Node process.

highlightCodeLines calls codeToTokens with github-dark. Each returned ThemedToken becomes a React span. If highlighting fails, the component returns uncolored plain lines instead of failing the article render.

That fallback matters because syntax highlighting is presentation, not content availability. A missing grammar should make the code less colorful, not make the post unreadable.

Moving syntax colors into the design system

The semantic variables live in code-tokens.ts:

1
export const CODE_TOKEN_VARS = {
2
foreground: "var(--code-foreground)",
3
comment: "var(--code-comment)",
4
keyword: "var(--code-keyword)",
5
string: "var(--code-string)",
6
function: "var(--code-function)",
7
constant: "var(--code-constant)",
8
entity: "var(--code-entity)",
9
variable: "var(--code-variable)",
10
tag: "var(--code-tag)",
11
} as const;

The code-panel scope provides their actual values:

1
.code-snippet__panel {
2
--code-background: var(--sidebar);
3
--code-foreground: var(--sidebar-foreground);
4
--code-comment: #9aa8c0;
5
--code-keyword: #f97583;
6
--code-string: #9ecbff;
7
--code-function: #79b8ff;
8
--code-constant: #79b8ff;
9
}

resolveCodeTokenColor maps known github-dark values to those semantic variables:

1
const GITHUB_DARK_TO_TOKEN: Record<string, string> = {
2
"#6a737d": CODE_TOKEN_VARS.comment,
3
"#6e7681": CODE_TOKEN_VARS.comment,
4
"#959da5": CODE_TOKEN_VARS.comment,
5
"#e1e4e8": CODE_TOKEN_VARS.foreground,
6
"#f97583": CODE_TOKEN_VARS.keyword,
7
"#9ecbff": CODE_TOKEN_VARS.string,
8
// ...
9
};
10
11
export function resolveCodeTokenColor(hexColor?: string): string {
12
if (!hexColor) return CODE_TOKEN_VARS.foreground;
13
return GITHUB_DARK_TO_TOKEN[hexColor.toLowerCase()] ?? hexColor;
14
}

The important integration point is tokenStyle inside the highlighter. It calls the resolver for every Shiki token before React creates the span:

1
function tokenStyle(token: ThemedToken): CSSProperties {
2
return { color: resolveCodeTokenColor(token.color) };
3
}

This turns the design system into the final color authority. Changing --code-comment updates every mapped comment without changing the Shiki configuration or the rendering component.

Measuring the colors on the real panels

The default light canvas resolves --sidebar to #0b1422. On the dark canvas it resolves to the slightly darker #070d18. The table uses the WCAG relative-luminance formula against both surfaces.

Role

Token color

Light-canvas panel

Dark-canvas panel

AA for normal text

Original GitHub Dark comment

#6a737d

3.83:1

4.04:1

Fail

Remapped comment

#9aa8c0

7.68:1

8.09:1

Pass

Foreground

#eef2fa

16.46:1

17.33:1

Pass

Keyword

#f97583

6.95:1

7.32:1

Pass

String

#9ecbff

10.94:1

11.52:1

Pass

Constant and function

#79b8ff

8.89:1

9.37:1

Pass

Entity

#b392f0

7.29:1

7.67:1

Pass

Variable

#ffab70

9.94:1

10.48:1

Pass

Tag

#85e89d

12.33:1

12.99:1

Pass

The comment remap fixes the actual contrast failure. Most other mapped colors already cleared 4.5:1, but expressing them semantically gives the system one place to own and review the palette.

Choosing what happens to unknown colors

When Shiki emits a color absent from the map, the resolver returns the original hex value. That is graceful degradation, not contrast enforcement.

  • Passing the color through preserves syntax distinctions when the upstream theme introduces a value I have not mapped.
  • Falling back to the foreground would favor readability but flatten some distinctions.
  • Testing every emitted color against the panel background could catch new failures automatically, but the repository does not do that today.

The current choice keeps highlighting intact while accepting that an unknown token color has not been audited. The table above covers the semantic palette, not every future value Shiki might emit.

Why use a local resolver?

Shiki already supports colorReplacements, custom themes, and CSS-variable themes. Because this component consumes codeToTokens and renders React spans itself, the final rendering boundary is also a convenient place to translate Shiki colors into design-system variables.

The local resolver is not inherently more correct than Shiki's built-in replacement facilities. Its advantage in this codebase is visibility: the mapping is a small pure function with direct unit tests, and the React renderer already has to inspect every token. A future refactor could move the same policy into colorReplacements without changing the central idea.

Payload line annotations

The CMS calls annotations Blips. In the public interface they appear as optional + buttons attached to specific one-based line numbers. Activating one opens a dialog containing a short explanation and, optionally, a link.

1
{
2
row: number;
3
label: string;
4
feature: string;
5
enableLink?: boolean;
6
linkLabel?: string;
7
linkUrl?: string;
8
}

When annotations exist, CodeSnippet wraps the panel with CodeSnippetInteractive. Each trigger uses the annotation label as its aria-label, displays a decorative plus icon, and opens a Radix Dialog.

The current implementation is not the end of the accessibility story. Radix manages focus trapping, Escape handling, and focus return, but the dialog content does not render Dialog.Title or establish an equivalent accessible name. Until that is corrected and tested, I treat annotation-dialog accessibility as incomplete.

Without JavaScript, the highlighted code remains readable, but the annotation buttons cannot open their explanations. The base snippet therefore degrades safely while the annotation experience itself still depends on JavaScript. I have not yet used annotations in a published post, so they remain an optional CMS capability rather than a dependency of the editorial format.

Where contrast awareness stops

Color contrast is only one part of accessible code presentation. The current component uses a <figure> with an optional <figcaption>, but the highlighted source is rendered as a div line grid rather than <pre><code>. Screen-reader traversal and preservation of code semantics therefore need further review.

Wide code uses an internal horizontal scroll container. A Playwright fixture verifies that it remains inside the panel instead of expanding the document. That test does not verify keyboard scrolling or cross-browser focus behavior.

The copy button is in better shape. It exposes state-specific labels for copying, success, and failure; hides decorative icons; and has tests for clipboard behavior. Reduced-motion styles disable its transitions when the reader requests them.

These boundaries are why I describe the implementation as contrast-aware, not as proof that the complete component is accessible.

Testing the guarantees that matter

Test area

What it verifies

What it does not verify

code-tokens.test.ts

Known colors map to semantic variables; unknown colors pass through; missing colors use the foreground

Computed contrast or rendered appearance

languages.test.ts

Language normalization and Payload field parsing

Highlighted output

CodeSnippet.test.tsx

Structure, escaping, dark-panel attributes, and default chrome

Real Shiki colors because the highlighter is mocked

CopyCodeButton.test.tsx

Accessible labels and clipboard success or failure

Full-page interaction

preprocess-highlight.test.ts

Highlight markers and ranges are removed correctly

Whether emphasis has sufficient non-color cues

e2e/code-snippet.spec.ts

Fixture rendering, contained overflow, and copying in Chromium

Annotation dialogs, keyboard scrolling, and cross-browser behavior

Unit tests make the mapping deterministic. The contrast table checks the current resolved colors. Neither replaces semantic, keyboard, or assistive-technology testing.

Content Security Policy and inline colors

The site does not currently send a Content Security Policy header, so the renderer's style={{ color: ... }} attributes are allowed. A stricter policy would make that decision visible.

A nonce on a <style> element does not authorize the style="" attributes attached to individual token spans. CSP Level 3 controls those through style-src-attr. Supporting a policy that blocks inline style attributes would require either permitting them deliberately or moving token colors into classes.

Shiki offers transformerStyleToClass for class-based output, but this repository does not use it and currently renders tokens through React rather than Shiki's HTML pipeline. A future CSP hardening pass would therefore need either a class-based React renderer or a change in highlighting pipeline.

Tradeoffs

Exact-color mapping couples the site to github-dark. If an upstream theme release changes a hex value, that color passes through until the table is updated.

Unknown-color passthrough favors continuity over enforcement. The code stays highlighted, but new colors are not automatically measured.

Inline styles are convenient but constrain future CSP policy. Moving to classes would make a stricter style-src-attr policy easier to adopt.

Interaction remains proportional. Plain snippets remain server-rendered markup. Copying, tabs, and annotations add client components only when an editor enables them.

The reusable lesson

A syntax theme is an input. The application's design system owns the colors readers actually see and the contrast requirements those colors must meet against the real surface.

In this implementation, Shiki tokenizes the code on the server. A small resolver translates known theme colors into semantic CSS variables. The code panel supplies the final values, and measured contrast verifies those values against both navy backgrounds.

That resolver is the contract point: tokenization from Shiki, presentation from CSS, and contrast policy from measured values on the actual panel. Copying, tabs, and annotations remain optional layers on top of the static highlighted output.

That is the split I would reuse for the next dark-panel technical blog.


Portrait of Daniel Mark

Written by

Daniel Mark

Senior Frontend Engineer

Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.