Sam Hooke

Pure CSS dark mode support for code highlighting

These notes cover how to add support for code highlighting that automatically respects the user’s lightβ˜€οΈ/darkπŸŒ‘ mode configuration using pure CSS. No JavaScript!

In short, we start with two CSS themes, smash them together using a Python script into some CSS variables, then use the CSS @media block to choose which CSS variables to load depending upon whether prefers-color-scheme is dark or light.

I’m using Hugo for the static site generation, but most of the steps are still applicable for non-Hugo websites. Only the parts that touch hugo.toml should need moifying for non-Hugo websites.

I’m also using Chroma to get the initial lightβ˜€οΈ and darkπŸŒ‘ CSS theme, but again, the same process should apply for non-Chroma themes.

Background Β§

First, let’s be clear on the distinction between Chroma, Themes and Hugo.

Chroma Β§

Chroma is a syntax highlighter written in Go. It supports syntax highlighting source code for many, many programming languages, and outputs the source code as HTML.

Themes Β§

There are many themes for Chroma, including both lightβ˜€οΈ and darkπŸŒ‘ themes.

Hugo Β§

Hugo is a static site generator, which uses Chroma as the default tool for code highlighting.

Problem Β§

By default, when Chroma is enabled for code highlighting in Hugo, it outputs HTML with style attributes.

For example, this Python code:

while True:
   print("Hello, world!")

Becomes this HTML:

<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4"><code class="language-python" data-lang="python"><span style="display:flex"><span><span style="color:#66d9ef">while</span> <span style="color:#66d9ef">True</span>:
</span></span><span style="display:flex"><span>   print(<span style="color:#e6db74">"Hello, world!"</span>)
</span></span></code></pre></div>

Since the style attributes are used for applying the theme, we cannot change the theme (without resorting to JavaScript, which we will not).

If we can get Chroma to apply the theme using classes, then that opens up the possibility for changing the theme using pure CSS.

Fortunately, we can do exactly that by setting noClasses = false in our Hugo configuration:

hugo.toml
[markup]
  [markup.highlight]
    # ...
    noClasses = false  # Use `class` instead of `style` in Chroma generated HTML

Then for the same Python code as above, we instead get this HTML:

<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">   <span class="nb">print</span><span class="p">(</span><span class="s2">"Hello, world!"</span><span class="p">)</span>
</span></span></code></pre></div>

Using Hugo, we can then generate CSS for Chroma themes that use these classes. For example, if we run:

hugo gen chromastyles --style=monokai > syntax.css

We get a CSS file which looks like:

/* Background */ .bg { color: #f8f8f2; background-color: #272822; }
/* PreWrapper */ .chroma { color: #f8f8f2; background-color: #272822; }
/* Other */ .chroma .x {  }
/* Error */ .chroma .err { color: #960050; background-color: #1e0010 }
/* CodeLine */ .chroma .cl {  }
/* ...etc... */

The challenge then is: how can we switch between Chroma themes using pure CSS?

Solution overview Β§

The general approach for the solution is:

  1. Update the hugo.toml file to generate classes.
  2. Choose a lightβ˜€οΈ and darkπŸŒ‘ theme.
  3. Extract the CSS files for the lightβ˜€οΈ and darkπŸŒ‘ theme using the hugo gen chromastyles command.
  4. Combine the two themes into a single theme, combined.css, making use of @media to switch between them. (This is where the interesting stuff happens!)
  5. Include combined.css as part of the website’s CSS.

Update the hugo.toml Β§

First, we must update the [markup.highlight] section of hugo.toml and set noClasses = false. This ensures that when Hugo renders the code blocks as HTML, it includes the CSS classes, rather than doing the selected style inline. This means you must supply your own CSS file1, which is the purpose of the combined.css file. We can also remove the style option, since that is ignored when noClasses = false:

hugo.toml
[markup]
  [markup.highlight]
    anchorLineNos = false
    codeFences = true
    guessSyntax = false
    hl_Lines = ''
    hl_inline = false
    lineAnchors = ''
    lineNoStart = 1
    lineNos = false
    lineNumbersInTable = true
    noClasses = false  # *MUST* be false
    noHl = false
    # style = 'monokai'  # <-- Can delete this
    tabWidth = 4

Choose the themes Β§

Originally this website used the monokai theme, which was applied to all code blocks regardless of whether dark mode was enabled.

Before adding support for lightβ˜€οΈ and darkπŸŒ‘ code highlighting, I decided to pick matching lightβ˜€οΈ and darkπŸŒ‘ themes. To help choose, I used these galleries of Chroma themes:

I chose the github theme for lightβ˜€οΈ and the github-dark theme for darkπŸŒ‘.

Extract the themes Β§

Hugo comes with a built-in command which makes it easy to extract the CSS for the themes:

hugo gen chromastyles --style=github > github-light.css
hugo gen chromastyles --style=github-dark > github-dark.css

Combine the themes Β§

⬇️ Download this Python script .

It is a standalone Python script which uses only the Python standard library, and should work on Python 3.12 and above.

Run the script as follows to generate the CSS variable files and the combined file:

python css_combine.py --css-light=github-light.css --css-dark=github-dark.css

It will output combined.css in the same directory, which contains:

  • CSS variables for the lightβ˜€οΈ theme.
  • CSS variables for the darkπŸŒ‘ theme, within an @media (prefers-color-scheme: dark) { ... } block.
  • A combined theme which is populated by CSS variables.

For examples of what these files look like, see the appendix.

Include the combined CSS file Β§

My website has a main stylesheet written in SCSS called main.scss. To include the combined theme, I use @use:

@use "chroma/combined.scss";

Previously, SCSS files used @import to include CSS files, however it had a couple of annoying constraints: the imported files had to have a leading underscore and had to have the *.scss file extension, else the standard CSS @import directive would be used. However, since Sass v1.23.0 (2019-10-02) the @use rule has been added, which makes it possible to include CSS without these constraints!

Conclusion Β§

That’s it!

By toggling lightβ˜€οΈ/darkπŸŒ‘ mode in your browser or OS, the code highlighting on your website should also switch between lightβ˜€οΈ/darkπŸŒ‘ mode!

Why do it this way?

This is exactly the sort of thing CSS is intended for: defining the style of your website. Use JavaScript only when necessary. Not everyone has JavaScript enabled, and it shouldn’t be required for your website to display correctly.

Additionally, we respect the user’s choice of lightβ˜€οΈ/darkπŸŒ‘ mode (as specified by prefers-color-scheme), which provides a better user experience.

Appendix Β§

Example inputs and outputs Β§

For reference, here are some examples of what the input and output files look like:

Inputs Β§

Following are the lightβ˜€οΈ and darkπŸŒ‘ theme files that get passed into css_combine.py:2

github-light.css
/* Background */ .bg { background-color: #ffffff; }
/* PreWrapper */ .chroma { background-color: #ffffff; }
/* Other */ .chroma .x {  }
/* Error */ .chroma .err { color: #a61717; background-color: #e3d2d2 }
/* CodeLine */ .chroma .cl {  }

/* ...snip... */

/* GenericStrong */ .chroma .gs { font-weight: bold }
/* GenericSubheading */ .chroma .gu { color: #aaaaaa }
/* GenericTraceback */ .chroma .gt { color: #aa0000 }
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
/* TextWhitespace */ .chroma .w { color: #bbbbbb }
github-dark.css
/* Background */ .bg { color: #c9d1d9; background-color: #0d1117; }
/* PreWrapper */ .chroma { color: #c9d1d9; background-color: #0d1117; }
/* Other */ .chroma .x {  }
/* Error */ .chroma .err { color: #f85149 }
/* CodeLine */ .chroma .cl {  }

/* ...snip... */

/* GenericStrong */ .chroma .gs { font-weight: bold }
/* GenericSubheading */ .chroma .gu { color: #79c0ff }
/* GenericTraceback */ .chroma .gt { color: #ff7b72 }
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
/* TextWhitespace */ .chroma .w { color: #6e7681 }

Outputs Β§

The variables contained within vars-light.css are defined in the :root block, so will be enbled by default. The variables contained within vars-dark.css are defined in a @media (prefers-color-scheme: dark) { ... } block within the :root block, so will only be enabled if dark mode is enabled. See this previous note for more details.3

Following are the CSS variable files that get generated.

First for the lightβ˜€οΈ theme:4

vars-light.css
:root {
  --chroma-background-color: unset;
  --chroma-background-background-color: #ffffff;
  --chroma-background-font-weight: unset;
  --chroma-background-font-style: unset;
  --chroma-pre-wrapper-color: unset;
  --chroma-pre-wrapper-background-color: #ffffff;
  --chroma-pre-wrapper-font-weight: unset;
  --chroma-pre-wrapper-font-style: unset;
  --chroma-other-color: unset;
  --chroma-other-background-color: unset;
  --chroma-other-font-weight: unset;
  --chroma-other-font-style: unset;

  /* ...snip... */

  --chroma-generic-traceback-color: #aa0000;
  --chroma-generic-traceback-background-color: unset;
  --chroma-generic-traceback-font-weight: unset;
  --chroma-generic-traceback-font-style: unset;
  --chroma-generic-underline-color: unset;
  --chroma-generic-underline-background-color: unset;
  --chroma-generic-underline-font-weight: unset;
  --chroma-generic-underline-font-style: unset;
  --chroma-text-whitespace-color: #bbbbbb;
  --chroma-text-whitespace-background-color: unset;
  --chroma-text-whitespace-font-weight: unset;
  --chroma-text-whitespace-font-style: unset;
}

Second for the darkπŸŒ‘ theme:5

vars-dark.css
@media (prefers-color-scheme: dark) {
  :root {
    --chroma-background-color: #c9d1d9;
    --chroma-background-background-color: #0d1117;
    --chroma-background-font-weight: unset;
    --chroma-background-font-style: unset;
    --chroma-pre-wrapper-color: #c9d1d9;
    --chroma-pre-wrapper-background-color: #0d1117;
    --chroma-pre-wrapper-font-weight: unset;
    --chroma-pre-wrapper-font-style: unset;
    --chroma-other-color: unset;
    --chroma-other-background-color: unset;
    --chroma-other-font-weight: unset;
    --chroma-other-font-style: unset;

    /* ...snip... */

    --chroma-generic-traceback-color: #ff7b72;
    --chroma-generic-traceback-background-color: unset;
    --chroma-generic-traceback-font-weight: unset;
    --chroma-generic-traceback-font-style: unset;
    --chroma-generic-underline-color: unset;
    --chroma-generic-underline-background-color: unset;
    --chroma-generic-underline-font-weight: unset;
    --chroma-generic-underline-font-style: unset;
    --chroma-text-whitespace-color: #6e7681;
    --chroma-text-whitespace-background-color: unset;
    --chroma-text-whitespace-font-weight: unset;
    --chroma-text-whitespace-font-style: unset;
  }
}

Here is the combined CSS file6. It is essentially identical to the two input files apart from where the var(--chroma-*) variables have been substituted in place of the original values.

combined.css
/* Background */ .bg { color: var(--chroma-background-color); background-color: var(--chroma-background-background-color); }
/* PreWrapper */ .chroma { color: var(--chroma-pre-wrapper-color); background-color: var(--chroma-pre-wrapper-background-color); }
/* Other */ .chroma .x {  }
/* Error */ .chroma .err { color: var(--chroma-error-color); background-color: var(--chroma-error-background-color) }
/* CodeLine */ .chroma .cl {  }

/* ...snip... */

/* GenericStrong */ .chroma .gs { font-weight: var(--chroma-generic-strong-font-weight) }
/* GenericSubheading */ .chroma .gu { color: var(--chroma-generic-subheading-color) }
/* GenericTraceback */ .chroma .gt { color: var(--chroma-generic-traceback-color) }
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
/* TextWhitespace */ .chroma .w { color: var(--chroma-text-whitespace-color) }

Project structure Β§

Before applying this change, my website’s file structure looked something like this. The parts relating to CSS are highlighted:

Old project structure
my-hugo-site/
β”œβ”€β”€ assets/
β”‚   └── scss/
β”‚       └── main.scss
β”œβ”€β”€ content/
β”‚   └── ...
β”œβ”€β”€ layouts/
β”‚   └── ...
β”œβ”€β”€ static/
β”‚   β”œβ”€β”€ css/
β”‚   β”‚   └── style.css
β”‚   β”œβ”€β”€ js/
β”‚   β”‚   └── ...
β”‚   β”œβ”€β”€ favicon.ico
β”‚   └── humans.txt
β”œβ”€β”€ themes/
β”‚   └── ...
β”œβ”€β”€ hugo.toml
β”œβ”€β”€ package.json
β”œβ”€β”€ package-lock.json
└── README.md

I chose to place the generated files inside assets/scss/chroma/, alongside the css_combine.py file, in case I want to change the themes again:

New project structure
my-hugo-site/
β”œβ”€β”€ assets/
β”‚   └── scss/
β”‚       β”œβ”€β”€ chroma/
β”‚       β”‚   β”œβ”€β”€ combined.css
β”‚       β”‚   β”œβ”€β”€ github-dark.css
β”‚       β”‚   β”œβ”€β”€ github-light.css
β”‚       β”‚   └── css_combine.py
β”‚       └── main.scss
β”œβ”€β”€ content/
β”‚   └── ...
β”œβ”€β”€ layouts/
β”‚   └── ...
β”œβ”€β”€ static/
β”‚   β”œβ”€β”€ css/
β”‚   β”‚   └── style.css
β”‚   β”œβ”€β”€ js/
β”‚   β”‚   └── ...
β”‚   β”œβ”€β”€ favicon.ico
β”‚   └── humans.txt
β”œβ”€β”€ themes/
β”‚   └── ...
β”œβ”€β”€ hugo.toml
β”œβ”€β”€ package.json
β”œβ”€β”€ package-lock.json
└── README.md

  1. See the Hugo documentation for more details. It covers using the hugo gen chromastyles command to generate the CSS file, and setting noClasses = false, just as we have done. ↩︎

  2. In full each one is 86 lines long, so I’ve snipped out the middle. ↩︎

  3. It is not permitted to put the @use within the :root block, so we must put the :root block within the code imported by @use. If it were possible, then instead of having two “vars” file and one “combined” file, we could just have two “theme” files and @use the relevant one, and remove the need for CSS variables for the syntax highlighting. However, it is useful to have CSS variables for the syntax highlighting so they can be used elsewhere in the stylesheet. For example, I use background-color: var(--chroma-pre-wrapper-background-color); to define the background colour for inline code blocks, so that the background colour matches that of the fenced code blocks. ↩︎

  4. I’ve snipped out the middle because in full it is 346 lines. ↩︎

  5. Again, I’ve snipped out the middle because in full it is 348 lines. The darkπŸŒ‘ variables have two more lines than the lightβ˜€οΈ variables because of the opening @media (...) { and closing } ↩︎

  6. I’ve snipped out the middle because in full it is 86 lines. ↩︎