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"], setdata-themeon<html>with a tiny inline script before the first paint, and replace the theme’s Sass color references withvar(--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
toCSSwith thedartsasstranspiler. hydrogen-hugotheme, 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) | Colors | Contrast |
|---|---|---|
| Body text on background | #3c4856 on #f8fafc | 8.91:1 |
| Headings on background | #17212b on #f8fafc | 15.57:1 |
| Links and accent on background | #0a7a52 on #f8fafc | 5.12:1 |
| Accent on card surface | #0a7a52 on #eef2f6 | 4.76:1 |
| Inline code | #b3155f on #f8fafc | 6.29:1 |
| Old accent, for comparison | #61eebc on #f8fafc | 1.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
onedarkstyle, 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-bgis defined once on:rootand the plain code text is pinned to#abb2bfin 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-linkclass withcolor: var(--c-primary) !importantoverrides them. .text-white. The templates use Bootstrap’s.text-whiteto mean “foreground”. In light mode it is remapped tovar(--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
- 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.csslists every rule that still hard-codes the background. - 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 itsbackground-colorcomes from the same theme. - Name tokens by role, not by value.
--c-fg-strongcan be white or near-black;--c-whitecannot, and the code would read wrong in one of the two themes. - 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 thetryaroundlocalStoragekeeps private browsing working. - Test with real input, not just page screenshots. The page itself looked right in light mode. Only filling in the form showed the bug.