Back to Home
post-thumb

Adding a Light Theme to a Dark-Only Hugo Theme

| October 16, 2026 | 9 min to read

Intro

Until now, this blog was always 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. By default it follows the device setting, and a manual toggle serves anyone who prefers otherwise.

The theme sets every color through Sass variables, and Sass resolves them 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. 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, and no token can override that. One line, background-color: var(--c-bg), fixed the form.

The Figure

Sensor lamp: the device is in light mode (prefers-color-scheme)Gels: the two palettes. Dark spots on the sheet: the code block, and the form field's compiled literalThe sheet is the page and the gels are the two palettes: every token is named by role, so one sheet reads right under either gel. The head script picks the gel before the first paint: Auto follows the device's prefers-color-scheme, Light and Dark are forced. The code block is painted dark on purpose and stays dark in both themes. The form field was painted too, by accident: Bootstrap compiled its background to #17212b, so under the light gel the typed text, #17212b, sat on #17212b at 1.00:1. The fix repaints it with var(--c-bg), giving 15.57:1.

The sheet on the projector is the page, and the two gels are the two palettes. Key 1 (or the middle of the figure) turns the knob through Auto, Light and Dark. Key 2 (or the left side) flips the light sensor. The sensor stands for the device’s prefers-color-scheme and only matters in Auto. Key 3 (or the right side) applies the fix. The code block is painted dark on the sheet on purpose, so it stays dark under either gel. The form field was painted dark by accident. That is the bug in Issue 1: under the light gel, its text drops to 1:1. The readout gives the field’s contrast for the current state. I generated the figure with the anatomy agent skill.

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 must 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;
  // ...
}

I interpolated the dark values from the original Sass variables, including the lighten() and desaturate() derivations that 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);
}

In the dark theme, $white means “the strongest foreground color”, not literally white. I named the token --c-fg-strong instead of --c-white. Then it can become #17212b in light mode, and the code does not lie 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 each text and background pair that uses the tokens 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

The main script is deferred. If it applies the theme, a visitor on a light device first sees a dark page flash. 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), and the CSS keys on it. data-theme-setting is what the visitor chose (auto, light, or dark), and the toggle uses it to pick its icon. The try is there because localStorage throws in some private browsing modes. The page then 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 that matches 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. That 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 tuned earlier for contrast on a dark background. A re-tune for light mode doubles the work for no real gain. So I define --c-code-bg once on :root and pin the plain code text to #abb2bf in light mode.
  • Category colors. Category labels get an inline style="color: ..." from front matter. I chose those colors 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, I remap it to var(--c-fg-strong) instead of 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. When I typed into them, nothing showed. The text was there, but it 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 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. I made two copies of contact/index.html, with the head script forced to light in one and dark in the other. Then I filled the fields with a script and took headless Chrome screenshots of both. One showed dark text on light fields, and the other showed white text on dark fields.

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 reads 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 only page screenshots. The page itself looked right in light mode. Only filling in the form showed the bug.

References