Light and dark code highlighting in Astro without JavaScript

Configure Shiki with two themes and no default colour, choose between them in CSS with prefers-color-scheme, and use a Shiki transformer, not a rehype plugin, to add file names and a copy button. Includes the contrast catch with the standard GitHub themes.

This site follows the visitor’s light or dark setting and remembers nothing, which rules out a theme toggle and anything that stores a preference. Code blocks needed to follow the same rule. Shiki, which Astro uses for highlighting, can do this with no script at all, but a few details decide whether it works.

Two themes, no default colour

astro.config.mjs
markdown: {
  shikiConfig: {
    themes: { light: 'github-light-high-contrast', dark: 'github-dark-high-contrast' },
    defaultColor: false,
  },
},

With defaultColor: false, Shiki no longer puts a fixed colour on each token. It writes both theme colours as custom properties, --shiki-light and --shiki-dark, and leaves the choice to you. The CSS is then short:

site.css
.astro-code,
.astro-code span { color: var(--shiki-light); }
@media (prefers-color-scheme: dark) {
  .astro-code,
  .astro-code span { color: var(--shiki-dark); }
}

I set the block’s background from my own colour tokens instead of the theme’s, so code follows the page rather than the theme’s idea of a background.

Check the contrast of the theme you pick

The standard GitHub light and dark pair failed a 4.5:1 contrast check on comments when I measured it. The -high-contrast variants of the same themes passed. The site has a test holding text to a contrast ratio in both schemes, which is how I found out; I wouldn’t have spotted grey comments by eye.

File names and a copy button: use a transformer

I wanted a file name above a block, written in the fence (```yaml title="flux/apps.yaml"), and a Copy button. My first thought was a rehype plugin, and that goes wrong in two ways. Shiki’s own rehype step runs at a point where a user plugin can’t reliably follow it, and the fence’s meta string, where the title lives, never reaches a rehype plugin at all.

A Shiki transformer sees both the rendered block and its meta. Its root hook can wrap the <pre> in a <figure>, add a <figcaption> when there is a title, and append the button.

The button ships with the hidden attribute, and a small script reveals it. If the script doesn’t run there is no button, rather than a control that does nothing.