􀀂􀀟􀀍􀀄 􀀂􀀘􀀋 Dark Mode

As previously hinted, I have been working on dark mode support for this site. It’s now live!

The site on an iPhone in light mode The site on an iPhone in dark mode

It will automatically enable itself if you are using dark mode in your browser or operating system. You can toggle it manually on the secret control panel if you’d like to see it in action on this site without enabling it on your whole system.

Here’s how I did it. Note that full code is at the very end.

Eliminating flash

Thanks to Gwern for the notes and code to make this work without flashing the wrong color before the page fully loads.

My solution looks sort of like this:

<style id="inlined-styles-root">
  /* Root styles are included in both dark and light mode.
   * Dark mode overrides some of them.
   */
  :root {
    --body-bg-color: white;
    --body-fg-color: black;
  /* ... */
  }
</style>
<style id="inlined-dark-theme-styles" media="all and (prefers-color-scheme: dark)">
  :root {
    --body-bg-color: black;
    --body-fg-color: white;
  /* ... */
  }
</style>
<script>
setTheme() {
  const lsvalue = localStorage.getItem("site-setting-theme") || "auto";
  const darkThemeStyles = document.getElementById("inlined-dark-theme-styles");
  if (lsvalue == 'auto') {
    darkThemeStyles.media = "all and (prefers-color-scheme: dark)";
  } else if (lsvalue == 'dark') {
    darkThemeStyles.media = "all";
  } else {
    darkThemeStyles.media = "not all";
  }
}
setTheme();
</script>

From there, you can expand to include more styles, some sort of user control to choose between dark mode, light mode, and auto, etc.

All of this loads and executes before the <body> tag even opens – all of the above code is inlined into the <head> of every page on the site. This means the user will never experience a flicker of the default color scheme before their own preference loads. That was harder to get right than I thought it would be.

Syntax highlighting for code blocks

Thanks to Brian Wigginton for his post explaining how to do this properly in Hugo.

We can use the same approach as above to show a good source code syntax highlighting theme, but we have to know a little about how Hugo does syntax highlighting.

Hugo uses chroma for this. Its enabled like this in your config file. (I use YAML config syntax, rather than the TOML default.)

markup:
  highlight:
    theme: monokailight

That configuration was what I was using before, but it only lets me choose one syntax highlighting theme this way. To dynamically switch syntax highlighting themes depending on the user’s dark mode preference, we have to do things differently.

  1. Do not select a theme at build time
  2. Tell chroma to generate classes, not hard-coded styles
  3. Pregenerate CSS for those classes
  4. Select between those classes using the same code that sets the media attribute from the previous section

That means that I change the above config to look like this instead:

markup:
  highlight:
    noclasses: false

And I generate the classes with:

hugo gen chromastyles --style=monokailight > themes/micahrl/layouts/partials/chromacss/monokailight.css
hugo gen chromastyles --style=monokai > themes/micahrl/layouts/partials/chromacss/monokai.css

And I include those as Hugo partials in the <head> template:

<style id="inlined-light-theme-styles" media="all and (prefers-color-scheme: light)">
  /* Light theme syntax highlighting is not part of the root styles, so we have to add another style element
   */
  {{ partial "chromacss/monokailight.css" . | safeCSS }}
</style>
<style id="inlined-dark-theme-styles" media="all and (prefers-color-scheme: dark)">
  :root {
    --body-bg-color: black;
    --body-fg-color: white;
  /* ... */
  }
  {{ partial "chromacss/monokai.css" . | safeCSS }}
</style>

And my JavaScript code changes its media attribute too:

<script>
setTheme() {
  const lsvalue = localStorage.getItem("site-setting-theme") || "auto";
  const darkThemeStyles = document.getElementById("inlined-dark-theme-styles");
  const lightThemeStyles = document.getElementById("inlined-light-theme-styles");
  if (lsvalue == 'auto') {
    darkThemeStyles.media = "all and (prefers-color-scheme: dark)";
    lightThemeStyles.media = "all and (prefers-color-scheme: light)";
  } else if (lsvalue == 'dark') {
    darkThemeStyles.media = "all";
    lightThemeStyles.media = "not all";
  } else {
    darkThemeStyles.media = "not all";
    lightThemeStyles.media = "all";
  }
}
setTheme();
</script>

Image colors for SVG sprites

I have some icons that are inlined automatically into my HTML pages at build time. I wrote about this in Inline FontAwesome SVGs in Hugo. These are black-on-transparent SVGs with no other colors, and they would look fine as white-on-transparent (that is, with inverted colors), but we have to do this ourselves.

All such SVG sprites, and any other images I know for sure will look good when inverted, get a CSS class dark-mode-invert-image-color added. For instance:

<img class="dark-mode-invert-image-color" alt="<image description>" src="data:image/svg+xml;base64,<image base64>" />

That’s styled like this:

.dark-mode-invert-image-color {
  filter: var(--dark-mode-image-inverter-filter);
}

And that lets the above solution for variables automatically invert the colors on images, but only for images that I know for sure will look good. For light mode, the filter is invert(0%);; for dark mode, it is invert(100%);.

iOS overscroll

On iOS (and maybe Android?), you can scroll “past” the bottom of the page and it will do a rubber band effect. You can also scroll “up” at the top for “pull to refresh”. The background of the pages “above” and “below” your content will be white, unless you apply a theme-color meta tag. The ones I use match the <body> background for the light/dark mode the user selects:

<meta id="meta-theme-light-color" name="theme-color" content="#FFFFFF" media="all and (prefers-color-scheme: light)">
<meta id="meta-theme-dark-color" name="theme-color" content="#000000" media="all and (prefers-color-scheme: dark)">

Then you have to use the same trick describe above to switch their media= attribute in JavaScript.

<script>
setTheme() {
  const lsvalue = localStorage.getItem("site-setting-theme") || "auto";
  const darkThemeStyles = document.getElementById("inlined-dark-theme-styles");
  const lightThemeStyles = document.getElementById("inlined-light-theme-styles");
  const darkThemeMeta = document.getElementById("meta-theme-dark-color");
  const lightThemeMeta = document.getElementById("meta-theme-light-color");
  if (lsvalue == 'auto') {
    darkThemeStyles.media = "all and (prefers-color-scheme: dark)";
    darkThemeMeta.media = "all and (prefers-color-scheme: dark)";
    lightThemeStyles.media = "all and (prefers-color-scheme: light)";
    lightThemeMeta.media = "all and (prefers-color-scheme: light)";
  } else if (lsvalue == 'dark') {
    darkThemeStyles.media = "all";
    darkThemeMeta.media = "all";
    lightThemeStyles.media = "not all";
    lightThemeMeta.media = "not all";
  } else {
    darkThemeStyles.media = "not all";
    darkThemeMeta.media = "not all";
    lightThemeStyles.media = "all";
    lightThemeMeta.media = "all";
  }
}
setTheme();
</script>

The favicon supports dark mode too!

This was actually already done, and described in the design updates post a few days ago.

In short, I use an SVG for the favicon, and SVGs support the same prefers-color-scheme queries that modern HTML documents do.

Hugo partial that does all of the above

Below is the code I use.

This is a Hugo partial that is copied into the <head> of every page. You should be able to see this code if you view source on this page, for instance. (Although the copy you find below may not be updated as I update the site, so there may be some drift over time.)

It also includes some JavaScript for handling the radio button toggle on the secret control panel.

The JavaScript is a bit different from the code samples I describe above. It follows a pattern I use for other site settings stored in localSettings. The gist is the same, though.

<!--CSS that applies to both light and dark mode.
    Dark mode can override individual settings here.
    -->
<style id="inlined-styles-root">
  :root {
    --body-bg-color: white;
    --body-fg-color: black;
    --header-fg-color: black;
    --nav-fg-color: black;

    --color-micahrl-mint: #a7e0a6;    /* the link bg color */
    --color-micahrl-teal: #A7EFB9;    /* Teal from the pfp */
    --color-micahrl-purple: #5F3258;  /* Purple from the pfp */

    /* This color will be close-ish to the background color.
     * Still useful for borders etc.
     */
    --body-fg-color-deemphasize-nontext: #ddd;

    /* This color will be used for text
     */
    --body-fg-color-deemphasize-text: gray;

    --link-fg-color: var(--body-fg-color);
    --link-bg-color: var(--color-micahrl-mint);
    --bold-link-fg-color: var(--link-bg-color);
    --bold-link-hover-fg-color: black;
    --target-bg-color: #faefd7;
    --on-page-link-bg-color: #f0bc4a;
    --button-bg-color: lightgray;

    --toc-bg-color: #e8f4ff;
    --blockquote-left-border-color: #e8e8e8;
    --table-header-bg-color: #e8e8e8;
    --footer-fg-color: grey;
    --body-metadata-fg-color: grey;

    --draft-notice-bg: lightpink;
    --future-notice-border-color: rebeccapurple;

    --simpletable-overflow-bg-color: darkgray;
    --simpletable-overflow-fg-color: white;

    --button-bg-color: #dcdcdc;
    --button-fg-color: var(--body-fg-color);

    --business-card-attribution-fg-color: gray;

    /* Sometimes you want to make it super obvious that something is a link,
     * by making it that default link blue color.
     * However, we have to change that for dark mode, so it has to be a variable.
     */
    --default-link-unvisited-fg-color: blue;

    /* Should be set to the same color as the Pygments theme we're using.
     * That way code blocks with syntax highlighting don't stand out from those without.
     */
    --code-bg-color: #dadada;

    /* filter: value applied to iamges with .dark-mode-invert-image-color class */
    --dark-mode-image-inverter-filter: invert(0%);

    /* For project cards */
    --project-card-status-retired-bg-color: pink;
    --project-card-status-complete-bg-color: lightseagreen;
    --project-card-status-backburner-bg-color: lightsalmon;
    --project-card-status-miantained-bg-color: cornflowerblue;
    --project-card-version-alpha-fg-color: pink;

    /* Inline tweets */
    --inline-tweet-dt-fg-color: rgb(101, 119, 134);
    --inline-tweet-username-fg-color: rgb(101, 119, 134);
    --inline-tweet-about-bg-color: rgb(75, 75, 172);
    --inline-tweet-about-fg-color: white;

    --redrum-noci-svg-fill-color: var(--color-micahrl-teal);
    --redrum-noci-svg-hover-fill-color: black;
  }

  .dark-mode-invert-image-color {
    filter: var(--dark-mode-image-inverter-filter);
  }
</style>

<!--Dark mode only CSS
    -->
<style id="inlined-dark-theme-styles" media="all and (prefers-color-scheme: dark)">
  :root {
    --body-bg-color: black;
    --body-fg-color: white;
    --body-fg-color-deemphasize-nontext: rgb(57, 57, 57);
    --header-fg-color: white;
    --nav-fg-color: white;
    --link-fg-color: black;
    --toc-bg-color: #00284e;
    --table-header-bg-color: #3d3d3d;
    --target-bg-color: #49350a;

    --code-bg-color: rgb(75, 75, 75);

    --dark-mode-image-inverter-filter: invert(100%);

    --default-link-unvisited-fg-color: rgb(127, 161, 255);

    --button-bg-color: rgb(112, 112, 112);
    --blockquote-left-border-color: gray;

    --redrum-noci-bg-color: var(--color-micahrl-purple);
  }
  {{ partial "chromacss/monokai.css" . | safeCSS }}
</style>

<!--Light mode only CSS
    -->
<style id="inlined-light-theme-styles" media="all and (prefers-color-scheme: light)">
  {{ partial "chromacss/monokailight.css" . | safeCSS }}
</style>

<!--This styles the iOS overscroll area
    -->
<meta id="meta-theme-light-color" name="theme-color" content="#FFFFFF" media="all and (prefers-color-scheme: light)">
<meta id="meta-theme-dark-color" name="theme-color" content="#000000" media="all and (prefers-color-scheme: dark)">

<!--Inline theme handling.
    Inspired by Gwern's solution.
    This method, which uses JavaScript to set the `media=` attribute on a `<style>` element
    as soon as possible (and especially before any content has loaded)
    avoids the flash of white before converting the whole site to dark mode (and vice versa)
    if the user has selected a mode that is different from their prefers-color-scheme value.
    -->
<script>
  var ThemeSetting = {

    /* Constants for easy reference later
     */
    localStorageKey: 'site-setting-theme',
    radioGroupId: 'site-setting-theme-radio-group',
    contentElemId: 'content',
    darkThemeStylesId: 'inlined-dark-theme-styles',
    darkThemeMetaColorId: 'meta-theme-dark-color',
    lightThemeStylesId: 'inlined-light-theme-styles',
    lightThemeMetaColorId: 'meta-theme-light-color',

    /* Convenience getters
     */
    get: function () {
      const lsvalue = localStorage.getItem(this.localStorageKey);
      return lsvalue ? lsvalue : "auto";
    },
    set: function (value) {
      return localStorage.setItem(this.localStorageKey, value);
    },
    radiogroup: function () {
      return document.getElementById(this.radioGroupId);
    },

    /* Read the theme setting from localStorage and apply it to the page
     */
    apply: function () {
      const lsvalue = this.get();
      const darkThemeStyles = document.getElementById(this.darkThemeStylesId);
      const lightThemeStyles = document.getElementById(this.lightThemeStylesId);
      const darkThemeMeta = document.getElementById(this.darkThemeMetaColorId);
      const lightThemeMeta = document.getElementById(this.lightThemeMetaColorId);
      if (lsvalue == 'auto') {
        darkThemeStyles.media = "all and (prefers-color-scheme: dark)";
        darkThemeMeta.media = "all and (prefers-color-scheme: dark)";
        lightThemeStyles.media = "all and (prefers-color-scheme: light)";
        lightThemeMeta.media = "all and (prefers-color-scheme: light)";
      } else if (lsvalue == 'dark') {
        darkThemeStyles.media = "all";
        darkThemeMeta.media = "all";
        lightThemeStyles.media = "not all";
        lightThemeMeta.media = "not all";
      } else {
        darkThemeStyles.media = "not all";
        darkThemeMeta.media = "not all";
        lightThemeStyles.media = "all";
        lightThemeMeta.media = "all";
      }
    },

    /* Set the page load state (including e.g. dark mode toggle buttons).
     * Should be called from the <body>'s onload event
     */
    setPageLoadState: function () {
      this.apply();
      if (!this.radiogroup()) {
        return;
      }
      for (elem of this.radiogroup().getElementsByTagName("input")) {
        if (elem.value == this.get()) {
          elem.checked = true;
        } else {
          elem.checked = false;
        }
      }
    },

    /* Should be called from the `onclick` of each option in the dark mode toggle radio button group
     */
    setRadioGroup: function() {
      for (elem of this.radiogroup().getElementsByTagName("input")) {
        if (elem.checked) {
          this.set(elem.value);
        }
      }
      this.apply();
    }
  }
  ThemeSetting.apply();
</script>

Responses

Comments are hosted on this site and powered by Remark42 (thanks!).

Webmentions are hosted on remote sites and syndicated via Webmention.io (thanks!).