Back to Home

Adding a Light Theme to a Dark-Only Hugo Theme

| October 16, 2026 | 7 min to read

Intro

This blog has always been dark. The hydrogen-hugo theme ships one palette: a #17212B background, light gray text, and a mint green accent. I wanted a light version too, following the device setting by default, with a manual toggle for anyone who prefers otherwise.

The theme sets every color through Sass variables, which are resolved at build time. A runtime toggle needs colors that can change in the browser, so the work was mostly converting Sass colors to CSS custom properties. It went smoothly until someone opened the contact page in light mode and typed into the form: the text was invisible.

TL;DR: Define the colors as CSS custom properties under [data-theme="dark"] and [data-theme="light"], set data-theme on <html> with a tiny inline script before the first paint, and replace the theme’s Sass color references with var(--c-*). Then check every rule you did not write yourself: Bootstrap compiles .form-control { background-color: $input-bg } to a literal #17212b, which no token can override. One line, background-color: var(--c-bg), fixed the form.

Prerequisites

  • Hugo 0.168.0 with Dart Sass 1.105.1, building the theme SCSS through toCSS with the dartsass transpiler.
  • hydrogen-hugo theme, vendored into the repo, on Bootstrap 5.2 SCSS.
  • Theme colors in the theme’s config.toml, injected into Sass as variables:
color_primary = "#61eebc"
body_color = "#17212B"
text_color = "#cbcbcb"
  • Hosted on Cloudflare Workers static assets (see the previous post ), so everything has to work as plain static files.

Setup

Step 1: Color tokens

I added one new file, assets/scss/_theme.scss, imported right after Bootstrap. It defines a token for every role a color plays, not for every color:

:root,
[data-theme="dark"] {
  color-scheme: dark;
  --c-bg: #{$body-bg};
  --c-fg: #{$text-color};
  --c-fg-strong: #{$white};
  --c-fg-strong-10: #{rgba($white, .1)};
  --c-border: #{$border-color};
  --c-primary: #{$color-primary};
  --c-input-focus: #{lighten($body-bg, 5)};
  --c-inverse-bg: #{$white};
  --c-inverse-fg: #{$black};
  // ...
}

[data-theme="light"] {
  color-scheme: light;
  --c-bg: #f8fafc;
  --c-fg: #3c4856;
  --c-fg-strong: #17212b;
  --c-fg-strong-10: rgba(23, 33, 43, .08);
  --c-border: #d5dce4;
  --c-primary: #0a7a52;
  --c-input-focus: #ffffff;
  --c-inverse-bg: #17212b;
  --c-inverse-fg: #ffffff;
  // Bootstrap's own variables
  --bs-body-bg: #f8fafc;
  --bs-body-color: #3c4856;
  --bs-border-color: #d5dce4;
  // ...
}

The dark values are interpolated from the original Sass variables, including the lighten() and desaturate() derivations the theme already used. That way the dark theme compiles to exactly the colors it had before, and I only had to design the light side.

Then I replaced the color references in the theme’s SCSS (_common, _typography, _buttons, templates/_main, templates/_navigation), 67 in total. Most were mechanical:

// before
.nav-link:hover {
  color: $white;
  background-color: rgba($white, .1);
}
// after
.nav-link:hover {
  color: var(--c-fg-strong);
  background-color: var(--c-fg-strong-10);
}

The naming matters here. In the dark theme, $white means “the strongest foreground color”, not literally white. Naming the token --c-fg-strong instead of --c-white is what lets it become #17212b in light mode without the code lying about what it does.

Step 2: Pick light colors that pass WCAG AA

The mint accent #61eebc is fine on the dark background but reaches only 1.39:1 on a light one. The light palette uses a darker green instead. I checked every text and background pair the tokens are used in against the 4.5:1 minimum:

Pair (light theme)ColorsContrast
Body text on background#3c4856 on #f8fafc8.91:1
Headings on background#17212b on #f8fafc15.57:1
Links and accent on background#0a7a52 on #f8fafc5.12:1
Accent on card surface#0a7a52 on #eef2f64.76:1
Inline code#b3155f on #f8fafc6.29:1
Old accent, for comparison#61eebc on #f8fafc1.39:1

Step 3: Set the theme before the first paint

If the theme is applied from the main script, which is deferred, a visitor on a light device sees a dark page flash first. The fix is a small inline script at the top of <head>, before the stylesheet:

<meta name="color-scheme" content="dark light">
<script>
(function () {
  var s = "auto";
  try { s = localStorage.getItem("theme") || "auto"; } catch (e) {}
  if (s !== "light" && s !== "dark") s = "auto";
  var d = document.documentElement;
  d.dataset.themeSetting = s;
  d.dataset.theme = s === "auto" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : s;
})();
</script>

It sets two attributes on <html>: data-theme is the resolved theme (light or dark) that the CSS keys on, and data-theme-setting is what the visitor chose (auto, light, or dark), which the toggle uses to pick its icon. The try is there because localStorage throws in some private browsing modes; the page then simply follows the device.

Step 4: The toggle

The button cycles Auto, Light, Dark. It holds three inline SVG icons (half circle, sun, moon) and CSS shows only the one matching data-theme-setting:

.theme-toggle > span { display: none; }
[data-theme-setting="auto"] .theme-toggle .icon-auto,
[data-theme-setting="light"] .theme-toggle .icon-light,
[data-theme-setting="dark"] .theme-toggle .icon-dark {
  display: inline-block;
}

The script stores light or dark in localStorage and removes the key for Auto, so Auto is the default and needs no storage. It also listens for device changes, which is what makes Auto follow a phone that switches to dark mode in the evening:

mq.addEventListener("change", function () {
  if (d.dataset.themeSetting === "auto") apply("auto");
});

Step 5: Things that stay the same in both themes

  • Code blocks stay dark. The syntax colors come from Chroma’s onedark style, which I had already tuned for contrast on a dark background. Re-tuning it for light mode would double the work for no real gain, so --c-code-bg is defined once on :root and the plain code text is pinned to #abb2bf in light mode.
  • Category colors. Category labels get an inline style="color: ..." from front matter, chosen for the dark background. Inline styles beat stylesheet rules, so in light mode a .category-link class with color: var(--c-primary) !important overrides them.
  • .text-white. The templates use Bootstrap’s .text-white to mean “foreground”. In light mode it is remapped to var(--c-fg-strong) rather than editing every template.

Issue 1: Invisible Text in the Contact Form

Symptom

After the theme went live, the contact page in light mode showed the Name, Email, Subject, and Message fields as dark boxes on the light page. Typing into them showed nothing at all. The text was there, it just had no contrast.

Cause

Bootstrap’s .form-control rule compiled to literal colors:

.form-control{...;color:#212529;background-color:#17212b;...}
# from Bootstrap's _form-control.scss: background-color: $input-bg
# $input-bg defaults to $body-bg, which the theme sets to #17212B

Sass resolves $input-bg at build time, so the CSS carries #17212b as a fixed value. Setting --bs-body-bg in light mode does nothing for this rule, because it never reads that custom property.

My own rule in _main.scss had already switched the text color to the token:

.form-control {
  color: var(--c-fg-strong) !important;
  // no background-color here
}

In dark mode that is white text on #17212b, as before. In light mode --c-fg-strong becomes #17212b, so the text was #17212b on a #17212b background: a contrast ratio of exactly 1:1. Converting the text color without the background is what turned a dark-looking field into an unusable one.

Fix

One line in the theme’s .form-control rule:

.form-control {
  border: 1px solid var(--c-border);
  color: var(--c-fg-strong) !important;
  // Bootstrap compiles its input background to a literal dark color
  background-color: var(--c-bg);
  &:focus {
    background-color: var(--c-input-focus);
  }
}

The theme’s rule comes after Bootstrap’s in the stylesheet, so it wins at equal specificity. In dark mode --c-bg is #17212b, the same color as before, so nothing changes there.

To check it, I built the site, made two copies of contact/index.html with the head script forced to light and dark, filled the fields with a script, and took headless Chrome screenshots of both. Dark text on light fields in one, white text on dark fields in the other.

Lessons

  1. CSS custom properties only reach rules that read them. Every Sass variable in a framework you did not write is a literal color in the output. After converting your own SCSS, search the compiled CSS for the old hex values: grep -oiE '[^}]*#17212b[^}]*' style.css lists every rule that still hard-codes the background.
  2. Convert colors in pairs. A foreground token next to a literal background works in one theme and breaks in the other. Whenever a rule sets color, make sure its background-color comes from the same theme.
  3. Name tokens by role, not by value. --c-fg-strong can be white or near-black; --c-white cannot, and the code would read wrong in one of the two themes.
  4. Set the theme inline, before the stylesheet. A deferred script applies the theme after the first paint and flashes the wrong one. Ten lines in <head> avoid that, and the try around localStorage keeps private browsing working.
  5. Test with real input, not just page screenshots. The page itself looked right in light mode. Only filling in the form showed the bug.

References