Files
linkbeam/HTML Theme Switcher Implementation.md

3.9 KiB

What It Does

A three-option theme toggle (System/Light/Dark) that works reliably across all browsers.

Features:

  • Cycles through: System (💡) → Light (☀️) → Dark (🌙)
  • Persists across page refreshes
  • Follows OS theme preference when "System" is selected
  • Works in private browsing mode
  • Mobile-friendly (icon-only)

The Solution

CSS

Three separate rules that work together:

/* Default: Light mode */
:root {
  --bg-primary: var(--nord-snow-storm-3);
  --text-primary: var(--nord-polar-night-2);
  /* ... */
}

/* System dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: var(--nord-polar-night-1);
    --text-primary: var(--nord-snow-storm-3);
    /* ... */
  }
}

/* Manual overrides (highest priority) */
html[data-theme="dark"] {
  --bg-primary: var(--nord-polar-night-1);
  --text-primary: var(--nord-snow-storm-3);
  /* ... */
}

html[data-theme="light"] {
  --bg-primary: var(--nord-snow-storm-3);
  --text-primary: var(--nord-polar-night-2);
  /* ... */
}

Why this works: html[data-theme] has higher specificity than the media query, so manual selection always overrides system preference.

JavaScript

const THEME_OPTIONS = ['system', 'light', 'dark'];

function toggleTheme() {
    const current = getFromStorage('themePreference', 'system');
    const nextIndex = (THEME_OPTIONS.indexOf(current) + 1) % 3;
    const next = THEME_OPTIONS[nextIndex];

    applyTheme(next);
}

function applyTheme(theme) {
    // Update HTML attribute
    if (theme === 'system') {
        document.documentElement.removeAttribute('data-theme');
    } else {
        document.documentElement.setAttribute('data-theme', theme);
    }

    // Update UI
    document.getElementById('themeIcon').textContent = THEME_ICONS[theme];
    document.getElementById('themeLabel').textContent = THEME_LABELS[theme];

    // Save preference
    saveToStorage('themePreference', theme);
}

// Initialize on load
initTheme();

Storage Fallback

Works even when localStorage is blocked:

function getStorage() {
    try {
        localStorage.setItem('__test__', '1');
        localStorage.removeItem('__test__');
        return localStorage;
    } catch {
        return sessionStorage; // Fallback
    }
}

Why It Works

  1. System mode: No data-theme attribute → media query applies
  2. Light mode: data-theme="light" → overrides media query
  3. Dark mode: data-theme="dark" → overrides media query

Firefox Fix

Problem: Initial implementation had invalid CSS:

/* ❌ Invalid */
html[data-theme="dark"],
@media (prefers-color-scheme: dark) { ... }

Solution: Separate rules:

/* ✅ Valid */
@media (prefers-color-scheme: dark) {
  :root { ... }
}

html[data-theme="dark"] { ... }

Tests

12 unit tests verify:

  • Theme storage/retrieval
  • localStorage → sessionStorage fallback
  • All three theme options work

Run: npm test

Available Themes

LinkBeam includes the following themes with automatic light/dark switching:

  1. auto - Simple default theme
  2. nord - Nord color palette (light/dark variants)
  3. gruvbox - Gruvbox color palette (light/dark variants)
  4. catppuccin-latte - Catppuccin Latte → Mocha (light → dark)
  5. catppuccin-frappe - Catppuccin Latte → Frappé (light → dark)
  6. catppuccin-macchiato - Catppuccin Latte → Macchiato (light → dark)
  7. catppuccin-mocha - Catppuccin Latte → Mocha (light → dark)

All themes follow the same CSS structure with:

  • Default light mode colors in :root
  • Dark mode colors in @media (prefers-color-scheme: dark)
  • Manual overrides via html[data-theme="light|dark"]

Files

  • templates/base.html - Theme switcher button and script
  • themes/*.css - Theme CSS files with light/dark variants
  • internal/config/config.go - Auto-discovers available themes

Status: Working in all browsers Last Updated: 2025-10-12