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:
Becomes this HTML:
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:
Then for the same Python code as above, we instead get this HTML:
Using Hugo, we can then generate CSS for Chroma themes that use these classes. For example, if we run:
We get a CSS file which looks like:
The challenge then is: how can we switch between Chroma themes using pure CSS?
Solution overview Β§
The general approach for the solution is:
- Update the
hugo.toml
file to generate classes. - Choose a lightβοΈ and darkπ theme.
- Extract the CSS files for the lightβοΈ and darkπ theme using the
hugo gen chromastyles
command. - 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!) - 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
:
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:
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:
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
:
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
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
Second for the darkπ theme:5
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.
Project structure Β§
Before applying this change, my website’s file structure looked something like this. The parts relating to CSS are highlighted:
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:
See the Hugo documentation for more details. It covers using the
hugo gen chromastyles
command to generate the CSS file, and settingnoClasses = false
, just as we have done. ↩︎In full each one is 86 lines long, so I’ve snipped out the middle. ↩︎
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 usebackground-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. ↩︎I’ve snipped out the middle because in full it is 346 lines. ↩︎
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}
↩︎I’ve snipped out the middle because in full it is 86 lines. ↩︎