Syntax Highlighting with Shiki in Astro and Eleventy

Documentation sites are full of code, and how that code is highlighted affects both performance and readability more than most teams expect. The traditional approach — ship Prism or highlight.js and let it colour code blocks after the page loads — costs every reader a JavaScript download, main-thread work on every page view, and a flash of plain text before colours appear. Highlighting at build time moves all of that to the build server once, and ships HTML with colours already applied.

Shiki is the build-time highlighter most static site generators have converged on. It uses the same TextMate grammars and themes as VS Code, so highlighting is accurate for real-world languages (including embedded languages such as CSS in HTML, or SQL in template strings), and it outputs plain HTML with inline styles or CSS variables. This guide sets up Shiki in Astro and Eleventy with light and dark themes, line highlighting and diff annotations, measures its build-time cost on a large documentation site, and compares it with client-side highlighting. It is part of Astro vs Eleventy for Documentation Sites.

Prerequisites

  • Astro 4 or 5, or Eleventy 2 or 3.
  • For Eleventy: shiki 1.x and @shikijs/transformers.
  • A site theme with both light and dark modes, driven by a data-theme attribute on <html> or by prefers-color-scheme.

Why Build-Time Highlighting Wins on Static Sites

Client-side highlighting was the default for a decade because it needed no build step. On a static site it is pure overhead: the code blocks never change after deploy, so every reader's browser repeats identical work. The numbers on a 1,300-page Eleventy documentation site that moved from Prism to Shiki:

MeasurePrism (client-side)Shiki (build time)
JavaScript for highlighting28 KB brotli (core + 14 languages)0 KB
Main-thread time on a code-heavy page (Lighthouse, 4× CPU)190 ms0 ms
Flash of unhighlighted codevisible for ~300 ms on slow 4Gnone
HTML size of a code-heavy page41 KB63 KB (+22 KB uncompressed)
HTML size after brotli9.1 KB11.4 KB

The HTML grows because every token carries a <span> with colour information, but brotli compresses that repetition well, and the net transfer (HTML plus JavaScript) dropped from 37 KB to 11 KB on the most code-heavy page.

Transfer size and main-thread time with Prism and Shiki Paired bars for a code-heavy documentation page. Total transfer of HTML plus highlighting JavaScript was 37 kilobytes with Prism and 11 kilobytes with Shiki. Main-thread time spent highlighting was 190 milliseconds with Prism and zero with Shiki. What a code-heavy page costs the reader Prism in the browser Shiki at build time Transfer HTML + JS (KB) 37 11 Main-thread highlighting (ms) 190 0
Lighthouse mobile profile with 4x CPU slowdown; transfer sizes are brotli-compressed.

Astro: Configure the Built-In Shiki

Astro highlights fenced code blocks with Shiki by default. The configuration below enables dual themes, sets a default language for unlabelled blocks, and adds transformers for line highlighting and diff notation:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import { transformerNotationDiff, transformerNotationHighlight, transformerMetaHighlight } from '@shikijs/transformers';

export default defineConfig({
  markdown: {
    shikiConfig: {
      themes: { light: 'github-light', dark: 'github-dark-dimmed' },
      defaultColor: false,
      wrap: false,
      langs: [],                         // bundled languages load on demand
      transformers: [transformerNotationDiff(), transformerNotationHighlight(), transformerMetaHighlight()]
    }
  }
});

defaultColor: false tells Shiki to emit only CSS variables (--shiki-light, --shiki-dark) rather than inline colours, so the stylesheet decides which theme applies. In components, the <Code> component from astro:components takes the same options, which is useful for code examples generated from data rather than written in Markdown.

Eleventy: Shiki as a Markdown Highlighter

Eleventy's Markdown renderer, markdown-it, accepts a highlight function. Shiki's createHighlighter is asynchronous, so create it once before the build and use its synchronous codeToHtml in the callback:

// eleventy.config.js
import { createHighlighter } from 'shiki';
import { transformerNotationDiff, transformerMetaHighlight } from '@shikijs/transformers';

export default async function (eleventyConfig) {
  const highlighter = await createHighlighter({
    themes: ['github-light', 'github-dark-dimmed'],
    langs: ['js', 'ts', 'json', 'yaml', 'toml', 'bash', 'html', 'css', 'go-html-template', 'nginx', 'diff']
  });

  eleventyConfig.amendLibrary('md', (md) => {
    md.options.highlight = (code, lang, attrs) =>
      highlighter.codeToHtml(code, {
        lang: highlighter.getLoadedLanguages().includes(lang) ? lang : 'text',
        themes: { light: 'github-light', dark: 'github-dark-dimmed' },
        defaultColor: false,
        meta: { __raw: attrs },
        transformers: [transformerNotationDiff(), transformerMetaHighlight()]
      });
  });
}

Loading only the languages the site uses keeps startup time down: loading all bundled grammars took 1.9 s on a CI runner, the eleven listed here 0.3 s. Unknown languages fall back to plain text instead of failing the build. The @shikijs/markdown-it plugin wraps the same idea if you prefer not to write the callback yourself.

Shiki highlighting pipeline in a static build A fenced code block with a language and meta string enters the Markdown renderer. Shiki tokenizes it with a TextMate grammar, applies both light and dark themes to each token, runs transformers for highlighted lines and diff markers, and outputs a pre element whose spans carry CSS variables for both themes. CSS picks the theme at render time. From fenced block to themed HTML Fenced block language and line meta Tokenize TextMate grammar Dual themes CSS variables per token Transformers highlight, diff Themed HTML pre.shiki, no JS
All work happens once per build; the browser receives finished markup and a few lines of CSS.

Switching Themes With CSS Only

With defaultColor: false, every token has both colours as variables, and a few CSS rules select one. They must follow the site's own theme switch, not just the operating system, so that a reader who chose dark mode in the site's toggle gets dark code:

.shiki, .shiki span { color: var(--shiki-light); background-color: var(--shiki-light-bg); }
:root[data-theme='dark'] .shiki,
:root[data-theme='dark'] .shiki span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .shiki,
  :root:not([data-theme='light']) .shiki span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); }
}

Check contrast in both themes. Several popular themes have comment colours that fall below 4.5:1 against their own background; github-light and github-dark-dimmed both passed axe's contrast rule on this site, while an earlier choice of a pastel dark theme failed on comments in 1,100 code blocks. Running the accessibility checks from Accessibility Testing with axe and pa11y in CI in both themes caught it.

Annotations Writers Can Use

Documentation code blocks often need emphasis: the two lines that changed, the line to edit, what was removed. Transformers turn simple notation into styled lines without writers touching HTML.

```js {3-4}
export default defineConfig({
  output: 'static',
  trailingSlash: 'always',
  build: { format: 'directory' }
});
```

```js
const cache = new Map(); const cache = {};        ```

The meta string {3-4} highlights lines 3 and 4; the [!code ++] and [!code --] comments become added and removed lines and are stripped from the output. Style them with a background tint and a left border, and add a visually hidden "added" or "removed" label so the meaning is not conveyed by colour alone.

Build-Time Cost and Caching

Shiki is not free at build time. TextMate grammars are regular-expression engines, and tokenizing thousands of blocks adds up.

SiteCode blocksHighlighting timeShare of build
Eleventy docs (1,300 pages)9,10011.2 s48%
Astro docs (410 pages)2,7004.1 s6%

For the Eleventy site, highlighting was nearly half the build. Because a code block's output depends only on its content, language, meta string and theme, it caches perfectly: hash those inputs and store the HTML. Eleventy's @11ty/eleventy-fetch cache directory, or a plain JSON file persisted between CI runs with actions/cache, is enough. With the cache warm, a build that changed a handful of pages highlighted only the new or edited blocks, and highlighting time fell to 1.6 s. The same caching approach applies to other expensive transforms, as described in Enabling Incremental Builds in Eleventy.

Shiki highlighting time with and without a cache Horizontal bars of highlighting time on the 9,100-block Eleventy build. All bundled grammars loaded and no cache, 13.1 seconds. Only used grammars and no cache, 11.2 seconds. Used grammars with a warm cache after a typical content change, 1.6 seconds. Highlighting time on the Eleventy docs build All grammars, no cache 13.1 s Used grammars, no cache 11.2 s Used grammars + warm cache 1.6 s
Median of five hyperfine runs on an 8-vCPU GitHub Actions runner.

Copy Buttons and Accessibility

Build-time highlighting does not remove the need for a little JavaScript if code blocks have copy buttons. Keep it small and separate: a 0.6 KB module that adds a button to each pre.shiki and copies textContent, loaded with type="module" so it never blocks rendering. Make code blocks keyboard-scrollable with tabindex="0" on <pre> when they overflow horizontally — axe flags scrollable regions that keyboard users cannot reach — and give each a label with the language when it is known. Diff markers and highlighted lines need a text equivalent, as noted above, because screen readers do not announce background colours.

Measured Impact

MeasureBefore (Prism)After (Shiki)
Highlighting JavaScript per page28 KB0 KB
Total Blocking Time on code-heavy page (lab)240 ms50 ms
LCP on code-heavy page, field p751.9 s1.6 s
Languages highlighted correctly (sample of 200 blocks)171197
Build time added (warm cache)0 s1.6 s
Contrast failures in code (axe, both themes)1,100 (dark)0

The accuracy improvement surprised the team most: Prism's grammars mis-tokenized Go templates, nginx configuration and TOML with inline tables in 29 of 200 sampled blocks, while Shiki's VS Code grammars handled all but three.

Pitfalls & Rollback

  • Loading every grammar. Adds seconds of startup; load only what the site uses and fall back to plain text.
  • Inline colours with a dark theme. Without defaultColor: false, the light colours are hard-coded and dark mode needs a second render.
  • Colour-only diff markers. Add text labels for added and removed lines.
  • Unscrollable code blocks for keyboard users. Add tabindex="0" to overflowing <pre> elements.
  • Rollback: highlighting is one configuration block in Astro and one callback in Eleventy; removing it returns plain <pre><code> blocks immediately.

Conclusion

Build-time highlighting with Shiki is one of the clearest wins available to a documentation site: no highlighting JavaScript, no flash of plain code, more accurate colours and dual themes selected by CSS. Astro gives it to you by default; Eleventy needs a twenty-line callback. On large sites, cache highlighted blocks between builds and the build-time cost almost disappears.

FAQ

Why highlight code at build time instead of in the browser?

Build-time highlighting ships finished HTML with colours already applied, so there is no highlighting library to download, no flash of unstyled code and no main-thread work on the reader's device. Client-side libraries such as Prism or highlight.js add 20 to 60 KB of JavaScript and run after load.

Does Astro include Shiki by default?

Yes. Astro highlights fenced code blocks in Markdown and MDX with Shiki out of the box, and provides a Code component for highlighting code in components. Configuration lives under markdown.shikiConfig in the Astro config.

How do I get light and dark themes with Shiki?

Use Shiki's dual-theme mode, which emits CSS variables for both themes on every token. A small CSS rule switches between them based on a data-theme attribute or prefers-color-scheme, with no second render and no JavaScript.

Is Shiki slow on large documentation sites?

It is slower than regex-based highlighters because it uses TextMate grammars, but the cost is paid at build time. On a site with about 9,000 code blocks it added roughly 11 seconds to an Eleventy build, which caching of highlighted output reduced to under 2 seconds on incremental builds.

Filed underHow-tosAstroEleventy