Implementing a Server-Rendered Theme System with Light/Dark/Auto Mode
A writeup on the approach used in this server, applicable to any server-rendered web app (Go, Ruby, Python, etc.).
Interaction diagram
Browser Server
─────── ──────
First visit (no cookies)
────────────────────────
GET /a/<id> ──────────────────────────────► resolveTheme()
no cookies → defaults:
darkID=asciinema, lightID=solarized-light
mode=auto → emit @media rule
◄────────────────────────────── HTML {
:root{--bg:#121314;--fg:#ccc}
@media(light){--bg:#fdf6e3;--fg:#657b83}
}
browser applies correct colors natively,
no JS needed for initial render
[JS runs]
updateOptgroups() → hide dark optgroup (OS is light)
select.value = 'solarized-light'
applyTheme() → visual no-op, CSS already correct
User picks a theme from dropdown (mode=auto, OS=light, showing light optgroup)
──────────────────────────────────────────────────────────────────────────────
[JS: select change]
id = 'solarized-dark' (user picked a dark theme... wait, optgroup is hidden)
→ can't happen: only light themes are shown in auto+light mode
id = 'tango' (another light theme)
lightID = 'tango'
setCookie(asciinema-theme-light, tango) ──────────────────────────────────► (stored in browser)
setProperty(--bg, #121314)
setProperty(--fg, #ccc)
mode stays 'auto'
User clicks Dark mode button
────────────────────────────
[JS: mode button click]
curMode = 'dark'
setCookie(asciinema-theme-mode, dark) ────────────────────────────────────► (stored in browser)
updateOptgroups() → show dark optgroup, hide light
activeThemeID() → darkID=asciinema
select.value = 'asciinema'
setProperty(--bg, #121314)
setProperty(--fg, #ccc)
User picks a different dark theme
──────────────────────────────────
[JS: select change]
id = 'dracula'
darkID = 'dracula'
setCookie(asciinema-theme-dark, dracula) ─────────────────────────────────► (stored in browser)
setProperty(--bg, #282a36)
setProperty(--fg, #f8f8f2)
User navigates to next page
────────────────────────────
GET /user/my-recordings ──────────────────────────────────────────────────► resolveTheme()
cookies: dark=dracula, light=tango, mode=dark mode=dark → activeID=dracula
bg=#282a36, fg=#f8f8f2
◄────────────────────────────────────────────────────────────── HTML { :root{--bg:#282a36;--fg:#f8f8f2} }
(correct, no JS correction needed)
[JS runs]
updateOptgroups() → show dark optgroup (mode=dark)
mode button 'dark' gets .active class
activeThemeID() → 'dracula' (already correct, applyTheme is no-op visually)
OS switches to light while page is open (mode=auto)
────────────────────────────────────────────────────
[OS event fires matchMedia listener]
curMode === 'auto' → yes
updateOptgroups() → hide dark optgroup, show light
activeThemeID() → lightID=tango
select.value = 'tango'
setProperty(--bg, #fdf6e3)
setProperty(--fg, #657b83)
onThemeChange('tango') → player recreated with tango theme
(no cookie written — OS change doesn't alter stored preferences)
Goals
- User picks a dark theme and a light theme independently
- A mode toggle (Dark / Auto / Light) controls which is active
- Auto follows the OS
prefers-color-schememedia query - No flash on page load — the correct background/foreground is rendered by the server before the browser paints anything
- Theme preference persists across pages and sessions
The core insight: cookies, not localStorage
localStorage is invisible to the server. Cookies are sent with every request.
By storing the preference in a cookie, the server can inject the correct colors
directly into the HTML — no JavaScript required to avoid a flash of wrong color.
Three cookies:
| Cookie | Values | Default |
|---|---|---|
asciinema-theme-dark |
any dark theme ID | asciinema |
asciinema-theme-light |
any light theme ID | solarized-light |
asciinema-theme-mode |
dark, light, auto |
auto |
Server side
1. Theme definitions
Each theme has an ID, a display label, a dark/light flag, and its background and foreground hex colors (copied from the theme's CSS):
type themeInfo struct {
ID string
Label string
Dark bool
Background string // e.g. "#121314"
Foreground string // e.g. "#cccccc"
}
2. Resolve the active theme from cookies
func resolveTheme(r *http.Request) pageTheme {
darkID := cookieOr(r, "asciinema-theme-dark", "asciinema")
lightID := cookieOr(r, "asciinema-theme-light", "solarized-light")
mode := cookieOr(r, "asciinema-theme-mode", "auto")
// Server can't know OS preference, so auto falls back to dark.
// JavaScript will correct this immediately on load if needed.
activeID := darkID
if mode == "light" {
activeID = lightID
}
t := themesByID[activeID]
return pageTheme{
ThemeBg: t.Background,
ThemeFg: t.Foreground,
DarkID: darkID,
LightID: lightID,
Mode: mode,
// also pass the full theme lists for rendering the dropdown
}
}
3. Inject colors as CSS custom properties
Embed pageTheme into every template data struct. In each page's <style>:
<style>
:root { --bg: {{.ThemeBg}}; --fg: {{.ThemeFg}}; }
body { background: var(--bg); color: var(--fg); }
/* Everything else uses var(--bg), var(--fg), or currentColor */
</style>
This is the only place template values appear in CSS. Every other rule uses
CSS custom properties or currentColor, so JavaScript can update the whole page
by changing just two properties on :root.
Client side
4. The theme dropdown
A single <select> with two <optgroup>s. Each <option> is styled with its
own background/color inline so the browser renders per-item colors in the
dropdown list:
<select id="sel-theme">
<optgroup label="Dark">
{{range .DarkThemes}}
<option value="{{.ID}}"
style="background:{{.Background}};color:{{.Foreground}}"
{{if eq $.DarkID .ID}}{{if ne $.Mode "light"}}selected{{end}}{{end}}>
{{.Label}}
</option>
{{end}}
</optgroup>
<optgroup label="Light">
{{range .LightThemes}}
<option value="{{.ID}}"
style="background:{{.Background}};color:{{.Foreground}}"
{{if eq $.LightID .ID}}{{if eq $.Mode "light"}}selected{{end}}{{end}}>
{{.Label}}
</option>
{{end}}
</optgroup>
</select>
The server pre-selects the correct option. For auto mode, the dark theme is
pre-selected as a safe fallback; JavaScript corrects it if the OS is light.
5. The mode toggle
Three icon buttons (sun / half-circle / moon) with title tooltips. The active
button gets an .active class:
<div id="mode-toggle" role="group" aria-label="Color mode">
<button type="button" data-mode="light" title="Light mode">☀ svg</button>
<button type="button" data-mode="auto" title="Auto (follow system)">◑ svg</button>
<button type="button" data-mode="dark" title="Dark mode">☾ svg</button>
</div>
6. The auto-mode dilemma
This is the hardest design problem in the whole system.
The problem: The user sets auto mode. The OS is dark, so the dark theme
is shown. The user opens the dropdown and picks a light theme to preview it.
What should happen?
Several options were considered:
-
Lock the mode to light — picking the opposite polarity exits auto. Simple, but violates the user's intent: they said "follow the OS" and a single click silently undoes that.
-
Apply it but stay in auto — the light theme shows in-session, but on the next page load auto kicks in again and the dark theme returns. The user's immediate choice is respected, but it evaporates on reload. Surprising and confusing.
-
Grey out the opposite polarity — show all themes but dim the ones that won't persist. Communicates intent but
<option>styling is unreliable across browsers. -
Only show same-polarity themes in auto — when in auto mode, the dropdown only lists themes that will actually take effect right now. To change your light theme preference you explicitly switch to Light mode, adjust, then switch back to Auto. This is what we implemented.
The key insight is that mode is an intent setting, not just a current-state setting. Showing only relevant themes makes the dropdown's effect unambiguous: whatever you pick will actually apply, both now and on the next page load.
The tradeoff: you can't browse all themes while in auto mode. This is acceptable — the workflow to change your light theme preference is explicit but not onerous.
7. JavaScript logic
A. Data
var themeBg = { 'asciinema': '#121314', 'solarized-light': '#fdf6e3', ... };
var themeFg = { 'asciinema': '#cccccc', 'solarized-light': '#657b83', ... };
var darkID = '{{.DarkID}}'; // server-resolved current dark preference
var lightID = '{{.LightID}}'; // server-resolved current light preference
var curMode = '{{.Mode}}'; // "dark" | "light" | "auto"
B. Apply a theme
function applyTheme(id) {
document.documentElement.style.setProperty('--bg', themeBg[id]);
document.documentElement.style.setProperty('--fg', themeFg[id]);
// if the page has a player/widget that needs recreating, call it here
}
Because every CSS rule uses var(--bg) / var(--fg), updating two custom
properties on :root instantly repaints the entire page correctly.
C. Show only the active polarity's optgroup
function activePolarityIsDark() {
if (curMode === 'dark') return true;
if (curMode === 'light') return false;
return osIsDark(); // auto
}
function updateOptgroups() {
var darkActive = activePolarityIsDark();
optgrpDark.hidden = !darkActive;
optgrpLight.hidden = darkActive;
}
Called on init, on every mode button click, and on OS preference change.
D. On dropdown change
Since only same-polarity themes are shown, the mode never changes on select:
selTheme.addEventListener('change', function() {
var id = selTheme.value;
if (activePolarityIsDark()) { darkID = id; setCookie(CKDARK, id); }
else { lightID = id; setCookie(CKLIGHT, id); }
applyTheme(id);
// no mode change, no updateModeButtons needed
});
E. On mode button click
modeButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
curMode = btn.dataset.mode;
setCookie(CKMODE, curMode);
updateOptgroups(); // show/hide the correct optgroup
var id = activeThemeID();
selTheme.value = id; // select the right theme for this mode
applyTheme(id);
updateModeButtons();
});
});
F. Initial setup — runs synchronously before first paint
updateOptgroups(); // hide the wrong optgroup
updateModeButtons(); // mark the right mode button as active
var id = activeThemeID();
selTheme.value = id; // select the correct option in the dropdown
applyTheme(id); // sync --bg/--fg in case JS changes them later
Note: for mode=auto the colors are already correct from CSS (see below) —
applyTheme here is a no-op visually, but keeps the JS state consistent for
subsequent user interactions.
G. Live OS preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
if (curMode === 'auto') {
updateOptgroups();
var id = activeThemeID();
selTheme.value = id;
applyTheme(id);
}
});
Eliminating the auto-mode flash entirely
The JS-based correction for auto mode works in practice, but it has a
theoretical flaw: it requires JS to execute before the first paint to avoid
showing the wrong colors. A slow device or a script error would cause a flash.
The real fix is to not use JS for the initial color selection at all in auto
mode — use a CSS @media rule instead, which the browser resolves natively:
/* mode=auto: server emits both, browser picks */
:root { --bg: #121314; --fg: #cccccc; } /* dark default */
@media (prefers-color-scheme: light) {
:root { --bg: #fdf6e3; --fg: #657b83; } /* light override */
}
For mode=dark or mode=light the server injects a single :root block as
before — no media query needed since the user has made an explicit choice.
The server branches on mode when rendering themeCSS:
const themeCSS = `{{if eq .Mode "auto"}}
:root { --bg: {{.AutoDarkBg}}; --fg: {{.AutoDarkFg}}; }
@media (prefers-color-scheme: light) {
:root { --bg: {{.AutoLightBg}}; --fg: {{.AutoLightFg}}; }
}{{else}}
:root { --bg: {{.ThemeBg}}; --fg: {{.ThemeFg}}; }{{end}}
body { background: var(--bg); color: var(--fg); }
`
AutoDarkBg/AutoDarkFg and AutoLightBg/AutoLightFg are the resolved
colors for the user's stored dark and light theme preferences respectively,
both passed from the server alongside ThemeBg/ThemeFg.
The rendering matrix is now:
| Mode | Server emits | JS needed for correct initial colors? |
|---|---|---|
dark |
single :root block |
No |
light |
single :root block |
No |
auto |
:root + @media (prefers-color-scheme: light) override |
No |
JS still runs to handle the dropdown, optgroup visibility, and mode buttons — but the initial render is always correct via pure CSS, even with JS disabled.
Sharing CSS and HTML across pages
With multiple pages each needing the same controls, define the shared pieces as
string constants and concatenate them into each template's <style> block:
const themeCSS = `
:root { --bg: {{.ThemeBg}}; --fg: {{.ThemeFg}}; }
body { background: var(--bg); color: var(--fg); }
`
const commonCSS = `
a { color: inherit; }
button { background: var(--bg); border: 1px solid currentColor; color: inherit; ... }
button:hover { background: var(--fg); color: var(--bg); }
input { background: var(--bg); color: inherit; border: 1px solid currentColor; ... }
/* etc */
`
// In each template:
var myTmpl = template.Must(template.New("page").Parse(`
<style>` + themeCSS + commonCSS + `
/* page-specific rules here */
</style>
`))
The theme bar HTML+JS is similarly a single string constant interpolated into every template at the appropriate position.
Summary of the flash-free guarantee
| Scenario | Colors correct without JS? |
|---|---|
| Returning user, mode=dark | Yes — single :root block |
| Returning user, mode=light | Yes — single :root block |
| Returning user, mode=auto, OS=dark | Yes — @media dark default applies |
| Returning user, mode=auto, OS=light | Yes — @media (prefers-color-scheme: light) override applies |
| First visit (no cookie), OS=dark | Yes — defaults to dark, @media dark default applies |
| First visit (no cookie), OS=light | Yes — @media (prefers-color-scheme: light) override applies |
Every case is handled by pure CSS. JS is only needed for interactive behavior (dropdown, mode buttons, player recreation) — never for correctness of the initial render.