Hugo + css = dark/light theme

Hugo + css = dark/light theme

Example on how one can implement different themes in CSS, this has nothing to do with Hugo itself. While having a light/dark (CSS) theme is not critical for a website some users do appreciate it and it is simple and fast to implement.

This article was written for Hugo 0.70.

Code

Switching themes can be accomplished by using built-in CSS features. You will have to use CSS variables, the gist is: define the default colors (and other variables) in :root and overwrite them for your theme(s), in our case dark the [data-theme="dark"] section. Now it is just a matter of toggling the data-theme attribute to dark and the dark theme values will be used on your site. You can add as many “themes” as you like, e.g. [data-theme="purple"], and let the user toggle them.

The Javascript is required to save the theme state (light/dark), locally on the machine of the user, so it is not reset when page is reloaded1. Therefore it is crucial this script is executed as fast as possible otherwise the page may initially show the wrong theme and noticeable flicker upon being corrected (resulting in a bad UX).

The script itself is straightforward, the only gotcha is "(prefers-color-scheme: dark)" which will be set if the user has its browser configured to prefer dark mode by default. The code can handle multiple “theme toggle” buttons on a single page and will synchronize their states upon changes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// These should be inlined
const themeAttribute = "data-theme";
const darkThemeName = "dark";
const normalThemeName = "light";

// Get the initial state
const darkModePreferredQuery = window.matchMedia("(prefers-color-scheme: dark)");
const currentTheme = localStorage.getItem(themeAttribute) || (darkModePreferredQuery.matches ? darkThemeName : normalThemeName);
document.documentElement.setAttribute(themeAttribute, currentTheme);

window.addEventListener("load", function () {
  const toggleSwitches = document.querySelectorAll(".theme-toggle");

  // Helpers
  const updateCheckboxState = (isChecked) => { // Because there may be multiple toggles on the same page
    toggleSwitches.forEach(t => {
      t.checked = isChecked;
    });
  };
  const changeTheme = (isDark) => {
    const newTheme = isDark ? darkThemeName : normalThemeName;
    document.documentElement.setAttribute(themeAttribute, newTheme);
    localStorage.setItem(themeAttribute, newTheme);

    updateCheckboxState(isDark);
  };

  updateCheckboxState(currentTheme === darkThemeName); // The initial state

  // Update when toggle button is changed
  toggleSwitches.forEach(t => {
    t.addEventListener("change", e => {
      changeTheme(e.target.checked);
    });
  });

  // Update when browser theme is changed
  if (darkModePreferredQuery.addEventListener) {
    darkModePreferredQuery.addEventListener("change", event => {
      changeTheme(event.matches);
    })
  }
});

To load the script as quickly as possible add it to your HTML head. Preferably before any other stylesheets (or scripts), for example see the snippet below.

1
2
3
4
5
6
7
8
9
{{ $themeToggleJS := resources.Get "js/theme.js" }}
{{ if .Site.IsServer }}
<script src="{{ $themeToggleJS.Permalink }}"></script>
{{ else }}
{{/*  Inlined because it is small enough  */}}
{{ with $themeToggleJS | minify }}
<script>{ { .Content | safeJS } }</script>
{{ end }}
{{ end }}

With just the Javascript the user can only change the theme through their browser preferences, usually websites provide a toggle switch for the user as well. Your theme toggle switch (or whatever control you like) will probably look different from mine, therefore i only provide the basic HTML. You can use any element you like, you just have to connect it to the script (through the elements id).

1
2
3
4
5
<div class="toggle-box">
    <input id="theme-switch" name="theme-toggle" type="checkbox" class="theme-toggle">
    <label for="theme-switch" class="theme-dark"></label>
    <label for="theme-switch" class="theme-light"></label>
</div>

Here is an example of the what a theme SCSS may look like. The default values are defined in :root and, if desired, overwritten for the dark theme in [data-theme="dark"]. Some variables are filled in by Hugo but this is entirely optional. You can also overwrite CSS rules in [data-theme="dark"]. Be careful how you define color variables, in hex or RGBA notation, for example color: rgba(var(--primary-color), 0.5) will not work if --primary-color is defined in hex but will work when it is defined in RGB.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
$background: {{ .Site.Params.backgroundColor | default "#FFFFFF" }};
$darkThemeBackground: {{ .Site.Params.darkBackgroundColor | default "#343a40" }};

{{ $defaultPrimary := .Site.Params.primaryColor | default "#54B689" }} // The default is also defined in head.html
$primary: {{ $defaultPrimary }};
$darkPrimaryColor: darken($primary, 30%);
$lightPrimaryColor: lighten($primary, 30%);
$darkThemePrimary: {{ .Site.Params.darkPrimaryColor | default $defaultPrimary }};

$text: {{ .Site.Params.textColor | default "#292929"  }};
$textSecondaryColor: lighten($text, 18%);
$textMutedColor: lighten($text, 35%);
$darkThemeText: {{ .Site.Params.darkTextColor | default "#e9ecef"  }};
$darkThemeTextSecondaryColor: darken($darkThemeText, 18%);
$darkThemeTextMutedColor: darken($darkThemeText, 35%);

:root {
    --bg-color: #{$background};

    --line-color: 0, 0, 0;

    --primary-color: #{$primary};

    // Font colors
    --font-color: #{$text};
    --font-color-light: #{$textSecondaryColor};
    --font-color-muted: #{$textMutedColor};

    --link-color: #{$text};
    --link-visited-color: #8440f1;
    --link-hover-color: #{$darkPrimaryColor};

    // Markdown
    --md-hint-info-color: 102, 187, 255;      // Must be in RGB
    --md-hint-warning-color: 255, 221, 102;   // Must be in RGB
    --md-hint-danger-color: 255, 102, 102;    // Must be in RGB
    --md-hint-normal: 91, 93, 94;             // Must be in RGB
    --md-bg-color-light: #f8f9fa;
    --md-bg-color-dark: #e9ecef;

    --twitter-blue: #55acee;

    // Fonts
    --font-family-sans-serif: -apple-system, BlinkMacSystemFont, 'Roboto', "Segoe UI", "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
    --font-family-monospace: 'Roboto Mono', SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  } 

  [data-theme="dark"] {
    --bg-color: #{$darkThemeBackground};

    --line-color: 255, 255, 255;

    --primary-color: #{$darkThemePrimary};

    --font-color: #{$darkThemeText};
    --font-color-light: #{$darkThemeTextSecondaryColor};
    --font-color-muted: #{$darkThemeTextMutedColor};

    --link-color: #84b2ff;
    --link-visited-color: #b88dff;
    --link-hover-color: #{$lightPrimaryColor};

    --md-bg-color-light: rgba(255, 255, 255, 0.1);
    --md-bg-color-dark: rgba(255, 255, 255, 0.2);
}

SCSS theme example

This is how you use colors variable in CSS, note that you can assign any value to variables.

1
2
3
.my-class {
    color: var(--primary-color);
}

CSS variable usage example


  1. for non static websites one can also tie the user theme preference to a user account and save it server side so the preference is retained across multiple machines. For static websites this is not possible however. ↩︎

Noticed an error in this post? Corrections are appreciated.

© Nelis Oostens