21 KiB
Theme System Guide
Project: WorkRoot IT Solutions — Company Site Date: 2026-03-21 Author: documentation-writer agent Status: Production-ready
Table of Contents
- Overview
- How Theme Selection Works (User Guide)
- Architecture & Implementation
- Design Tokens Reference
- Developer Guide: Adding Theme Support to New Components
- Maintaining Theme Consistency
- Troubleshooting Guide
- Accessibility & WCAG Compliance
- Performance Notes
1. Overview
The site supports dark and light themes with:
- Zero flash-of-unstyled-content (FOUC) — theme applies before first paint
- Automatic detection of the OS/browser dark mode preference
- Manual override persisted in
localStorage - Automatic sync when the OS preference changes (if no manual override)
- Full WCAG 2.1 AA/AAA contrast compliance in both themes
- Screen reader announcements on theme change
Files Involved
| File | Role |
|---|---|
src/styles/design-tokens.css |
Single source of truth for all color tokens |
src/components/ThemeToggle.astro |
Toggle button component (sun/moon icons) |
src/layouts/BaseLayout.astro |
Inline blocking script that prevents FOUC |
src/styles/global.css |
Component classes that consume tokens |
tailwind.config.mjs |
dark: variant configuration |
2. How Theme Selection Works (User Guide)
Automatic Detection
When you first visit the site, the theme is set automatically based on your operating system or browser preference:
- macOS: System Preferences → Appearance → Dark/Light
- Windows: Settings → Personalization → Colors → Choose your color
- Android/iOS: Display settings → Dark mode
No action needed — the site matches your system setting out of the box.
Manual Toggle
A sun/moon icon button in the navigation header lets you manually switch themes at any time:
- Moon icon (shown in light mode) → click to enable dark mode
- Sun icon (shown in dark mode) → click to return to light mode
Your choice is saved in your browser (via localStorage) and will persist across page navigations and browser restarts on the same device.
Priority Order
1. Manual user choice (localStorage) ← highest priority
2. OS/browser system preference
3. Light mode fallback ← default
Resetting to System Default
To go back to automatic system-based theming, clear your browser's site data for this domain:
- Chrome: DevTools → Application → Storage → Clear site data
- Firefox: DevTools → Storage → Local Storage → delete the
themekey - Safari: Develop → Website Data → remove site entry
3. Architecture & Implementation
FOUC Prevention (Blocking Inline Script)
The most critical piece — lives in BaseLayout.astro inside <head>, runs synchronously before any CSS or HTML renders:
<head>
<script>
// Runs BEFORE page paint — prevents flash of wrong theme
(function() {
try {
const stored = localStorage.getItem('theme');
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch(e) {}
})();
</script>
<!-- CSS loads here, already knowing which theme class is on <html> -->
</head>
Why inline? An external script would require a round-trip to the server, causing a visible flash. The inline IIFE runs synchronously as the browser parses the HTML.
CSS Custom Properties (Semantic Tokens)
Theme switching works entirely via CSS custom properties defined in src/styles/design-tokens.css. No JavaScript re-renders required.
/* Light mode (default) */
:root {
--color-surface: #f8fafc;
--color-text-primary: #1e293b;
--color-primary: #0891b2; /* cyan */
}
/* Dark mode override — applied when html.dark exists */
html.dark {
--color-surface: #0f172a;
--color-text-primary: #f1f5f9;
--color-primary: #22d3ee; /* brighter cyan for dark bg */
}
Components use semantic tokens, never raw color values:
/* ✅ Correct — adapts to theme automatically */
.card { background: var(--color-surface-raised); }
/* ❌ Wrong — hardcoded, breaks in dark mode */
.card { background: #ffffff; }
Tailwind Dark Variant
Tailwind classes with the dark: prefix apply when html.dark is present:
<!-- Text is dark on light bg, light on dark bg -->
<p class="text-secondary-700 dark:text-secondary-300">Content</p>
<!-- Card adapts its background -->
<div class="bg-white dark:bg-secondary-800 rounded-lg p-6">...</div>
The dark: variant is configured in tailwind.config.mjs:
// tailwind.config.mjs
export default {
darkMode: 'class', // uses html.dark class (not media query)
// ...
}
ThemeToggle Component
src/components/ThemeToggle.astro implements the interactive button.
Key behaviors:
- Multiple instances — uses
data-theme-toggleattribute +querySelectorAllto support both desktop and mobile nav toggles staying in sync - ARIA switch role —
role="switch"witharia-checkedupdated on each toggle - Live region —
[data-theme-live]announces "Dark mode enabled" / "Light mode enabled" to screen readers without interrupting reading flow - System preference listener —
matchMedia.addEventListener('change')updates theme automatically when OS preference changes (only if no manual override stored) - Theme-color meta — updates
<meta name="theme-color">so browser chrome (address bar) matches the theme
Theme State Machine
Initial load
│
├─ localStorage === 'dark' → apply dark
├─ localStorage === 'light' → apply light
├─ no localStorage + system dark → apply dark
└─ no localStorage + system light → apply light (default)
User clicks toggle
│
├─ add/remove html.dark class
├─ save 'dark'/'light' to localStorage
├─ update aria-checked on all [data-theme-toggle] buttons
├─ update aria-label on all buttons
├─ announce to [data-theme-live] live regions
└─ update all <meta name="theme-color"> tags
OS preference changes (no localStorage)
│
├─ toggle html.dark class
├─ update all [data-theme-toggle] aria-label/aria-checked
└─ update <meta name="theme-color">
4. Design Tokens Reference
All tokens live in src/styles/design-tokens.css. The file follows a two-layer approach:
- Primitive palette (
--palette-*) — Raw color scale values. Not used directly in components. - Semantic tokens (
--color-*) — Map primitives to intent. Use these in components.
Semantic Token Groups
Surfaces (backgrounds)
| Token | Light | Dark | Use Case |
|---|---|---|---|
--color-surface |
#f8fafc |
#0f172a |
Page background |
--color-surface-alt |
#f1f5f9 |
#1e293b |
Subtle variant |
--color-surface-raised |
#ffffff |
#1e293b |
Cards, modals |
--color-surface-inset |
#f1f5f9 |
#0f172a |
Inputs, code blocks |
--color-surface-sunken |
#e2e8f0 |
#080f1a |
Deeply inset areas |
Text
| Token | Light | Dark | WCAG |
|---|---|---|---|
--color-text-primary |
#1e293b |
#f1f5f9 |
AAA (14.7:1 / 14.3:1) |
--color-text-secondary |
#475569 |
#cbd5e1 |
AAA (6.6:1 / 9.2:1) |
--color-text-muted |
#64748b |
#94a3b8 |
AA (4.6:1 / 5.4:1) |
--color-text-disabled |
#94a3b8 |
#475569 |
Decorative only |
--color-text-inverse |
#ffffff |
#0f172a |
Text on opposite surface |
--color-text-link |
#0e7490 |
#22d3ee |
Interactive links |
Brand / Primary (Cyan)
| Token | Light | Dark |
|---|---|---|
--color-primary |
#0891b2 |
#22d3ee |
--color-primary-dark |
#0e7490 |
#67e8f9 |
--color-primary-light |
#22d3ee |
#67e8f9 |
--color-primary-subtle |
#ecfeff |
rgba(8,145,178,0.12) |
Borders
| Token | Light | Dark |
|---|---|---|
--color-border |
#e2e8f0 |
#334155 |
--color-border-strong |
#cbd5e1 |
#475569 |
--color-border-focus |
#22d3ee |
#22d3ee |
Status Colors
All status tokens follow the same pattern for both themes:
--color-{status} /* base */
--color-{status}-text /* text on light/dark surface */
--color-{status}-subtle /* very light/transparent background */
--color-{status}-border /* border color */
Statuses: success, warning, error, info
Component Shorthand Tokens
Convenience aliases that keep component classes concise:
--btn-primary-bg /* button background */
--btn-primary-text /* button label */
--btn-primary-hover /* button hover state */
--input-bg /* form input background */
--input-border /* form input border */
--card-bg /* card background */
--card-border /* card border */
--nav-bg /* header/nav background */
--nav-text /* nav link text */
5. Developer Guide: Adding Theme Support to New Components
Step 1: Use Semantic Tokens, Not Raw Colors
/* ❌ Hardcoded — breaks in dark mode */
.my-component {
background: #ffffff;
color: #1e293b;
border: 1px solid #e2e8f0;
}
/* ✅ Semantic tokens — adapts automatically */
.my-component {
background: var(--color-surface-raised);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
Step 2: Use Tailwind Dark Variants for Utility Classes
<!-- ❌ Light-only -->
<div class="bg-white text-slate-700 border-slate-200">
<!-- ✅ Both themes -->
<div class="bg-white dark:bg-secondary-800 text-secondary-700 dark:text-secondary-300 border-secondary-200 dark:border-secondary-700">
Step 3: Test Icon and SVG Colors
Icons using currentColor inherit from their parent's color property — they adapt automatically. Hardcoded fill or stroke values need dark variants:
<!-- ✅ Inherits theme color via currentColor -->
<svg class="text-primary dark:text-primary-400" fill="none" stroke="currentColor">
<!-- ❌ Hardcoded fill — won't adapt -->
<svg fill="#0891b2">
Step 4: Handle Gradients and Decorative Backgrounds
Hero sections use dark backgrounds that look fine in both themes. For sections that change between light/dark:
<!-- Section that adapts -->
<section class="bg-secondary-50 dark:bg-secondary-900 py-24">
<!-- Hero — dark in both themes by design (uses its own gradient) -->
<section class="bg-hero-dark py-32">
Step 5: Forms — Use Token-Based Classes
Form elements already have full dark-mode support via the form-input and form-label classes defined in global.css:
<!-- These automatically adapt to both themes -->
<label class="form-label" for="name">Name</label>
<input class="form-input" id="name" type="text" />
<span class="form-error">Required</span>
If you write a custom form element, mirror the token usage from global.css.
Step 6: New Astro Component Template
---
// src/components/MyComponent.astro
interface Props {
title: string;
variant?: 'default' | 'accent';
}
const { title, variant = 'default' } = Astro.props;
---
<div class:list={[
'rounded-lg p-6 border transition-colors duration-200',
'bg-white dark:bg-secondary-800',
'border-secondary-200 dark:border-secondary-700',
'text-secondary-700 dark:text-secondary-300',
variant === 'accent' && 'border-l-4 border-l-primary dark:border-l-primary-400'
]}>
<h3 class="font-semibold text-secondary-800 dark:text-secondary-100">
{title}
</h3>
<slot />
</div>
<style>
/* Use CSS custom properties for values not expressible in Tailwind */
div {
box-shadow: var(--card-shadow);
}
</style>
Step 7: Verify Contrast
Before shipping a new component, verify text contrast in both themes:
Tools:
- WebAIM Contrast Checker
- Chrome DevTools → Elements → Computed → contrast ratio badge
- Lighthouse accessibility audit
Minimum requirements:
- Body text: 4.5:1 (AA)
- Large text (18px+ / 14px+ bold): 3:1 (AA)
- Target: 7:1 (AAA) for critical text
All semantic text tokens in this design system already meet AA or AAA — use them and you're covered.
6. Maintaining Theme Consistency
The Golden Rules
- Never use raw hex colors in components — always use
var(--color-*)or Tailwind semantic classes - Always add
dark:variants when using Tailwind utility classes for colors - Test in both themes before committing new UI
- Use the existing token names — don't create new tokens unless there's a genuine gap
Checking for Theme Consistency Issues
Quick visual review process:
- Open the site in a browser
- Toggle to dark mode using the header button
- Navigate all pages looking for:
- White text on white background (contrast failure)
- Very dark text on dark background (contrast failure)
- Colorful decorative elements that disappeared (hidden in dark)
- Form inputs that look broken
- Repeat with
prefers-reduced-motionenabled in DevTools
Adding New Colors to the Token System
-
Add the primitive to
src/styles/design-tokens.cssin the:rootblock:--palette-brand-new-500: #ff6b6b; -
Add the semantic token for both light and dark modes:
:root { --color-brand-new: var(--palette-brand-new-500); } html.dark { --color-brand-new: var(--palette-brand-new-400); /* adjust for dark bg */ } -
Extend
tailwind.config.mjsif you need Tailwind utility class support:colors: { 'brand-new': { 400: '#ff8e8e', 500: '#ff6b6b', } } -
Verify contrast in both themes before using.
Don't Repeat Design Token Values
If you find yourself writing the same color value in multiple places, extract it to a token. Tokens are the single source of truth — changing a token value updates every component that uses it.
7. Troubleshooting Guide
Issue: Flash of wrong theme on page load (FOUC)
Symptoms: Page briefly shows light mode before switching to dark (or vice versa).
Cause: The inline blocking script in BaseLayout.astro is missing, disabled, or placed after CSS.
Fix: Ensure the theme detection script is the first <script> tag in <head>, before any <link> stylesheet tags. It must be synchronous (no defer, no async).
<head>
<!-- ✅ Script BEFORE stylesheets -->
<script>/* theme detection */</script>
<link rel="stylesheet" href="..." />
</head>
Issue: Theme toggle button doesn't respond
Symptoms: Clicking the sun/moon icon has no effect.
Diagnosis steps:
- Open browser DevTools → Console. Look for JavaScript errors.
- Check that
[data-theme-toggle]attribute exists on the button element. - Confirm
initThemeToggles()ran — addconsole.logafter the function call.
Common causes:
- The button HTML was modified and
data-theme-toggleattribute was removed - A JavaScript error elsewhere in
<script>blocks prevented the file from executing - The component was added to a page that blocks inline scripts via CSP
Issue: Theme preference not persisting across page loads
Symptoms: Theme resets to system default on every page load.
Cause: localStorage is unavailable (private browsing) or the key is being cleared.
Fix: This is expected behavior in private/incognito mode. In regular browsing, check:
// In browser console:
localStorage.getItem('theme'); // should return 'dark' or 'light'
If it returns null, the write is failing silently. Check the try/catch around localStorage.setItem in ThemeToggle.astro.
Issue: Dark mode colors look washed out or have poor contrast
Symptoms: Text is hard to read in dark mode.
Cause: Component is using hardcoded light-mode colors or Tailwind classes without dark: variants.
Diagnosis:
- Inspect the element in DevTools
- Look for
color,background-color, orborder-colorvalues that are light (near#fff) withoutdark:variants - Search the component file for raw hex values like
#fffffforwhite
Fix: Replace hardcoded values with semantic tokens or add dark: variants.
Issue: Component looks correct in light mode but invisible in dark mode
Symptoms: A card, badge, or section disappears or merges into the background in dark mode.
Common cause: Background and text colors both resolving to dark values.
Example:
<!-- Both bg and text are dark — text invisible in dark mode -->
<span class="bg-secondary-100 text-secondary-700">
<!-- dark:bg-secondary-100 is still nearly white, but there's no dark:text override -->
</span>
Fix: Ensure both background and text have explicit dark: variants:
<span class="bg-secondary-100 dark:bg-secondary-800 text-secondary-700 dark:text-secondary-200">
Issue: Icon colors not adapting to theme
Symptoms: SVG icons remain the same color in both themes.
Cause: Icon has a hardcoded fill or stroke attribute.
Fix: Use currentColor and set color via Tailwind:
<svg class="text-primary-600 dark:text-primary-400" fill="currentColor">
<path .../>
</svg>
Issue: System preference change not detected
Symptoms: Toggling OS dark mode doesn't update the site (only when no manual override).
Cause: The matchMedia change listener in ThemeToggle.astro is not attached.
Check: In browser console:
window.matchMedia('(prefers-color-scheme: dark)').matches
// toggle OS theme and check again
If the value changes but the site doesn't update, verify the event listener in ThemeToggle.astro is being registered (it may have errored before reaching that line).
Issue: Screen reader doesn't announce theme changes
Symptoms: Keyboard users toggle the theme but hear no announcement.
Cause: The [data-theme-live] <span role="status" aria-live="polite"> element is missing from the DOM, or its text content isn't being updated.
Fix: Ensure ThemeToggle.astro is rendering the live region element, and that announceThemeChange() is calling querySelectorAll('[data-theme-live]') (not querySelector).
Issue: Browser chrome (address bar) color doesn't match theme
Symptoms: The browser's native UI (address bar, tab bar on mobile) stays one color regardless of theme.
Cause: The <meta name="theme-color"> tag isn't being updated by applyTheme().
Check: In browser console after toggling:
document.querySelectorAll('meta[name="theme-color"]');
// Check the content attribute value
The applyTheme() function should update all meta[name="theme-color"] elements to #0f172a (dark) or #0891b2 (light).
8. Accessibility & WCAG Compliance
Contrast Ratios
All text tokens meet WCAG 2.1 AA at minimum. Most meet AAA.
| Token | Light Ratio | Dark Ratio | Level |
|---|---|---|---|
--color-text-primary |
14.7:1 | 14.3:1 | AAA |
--color-text-secondary |
6.6:1 | 9.2:1 | AAA |
--color-text-muted |
4.6:1 | 5.4:1 | AA |
| Primary brand color | 4.5:1 | 9.1:1 | AA/AAA |
| Accent (amber) | 3.0:1 | 11.5:1 | AA/AAA |
ARIA Implementation
The theme toggle uses role="switch" (semantically correct for on/off controls) with:
aria-checked="false"in light mode,"true"in dark modearia-labelupdated on each toggle ("Switch to dark mode" / "Switch to light mode")aria-hidden="true"on decorative SVG iconssr-onlylive region for screen reader announcements- Visible focus ring (
focus:ring-2) for keyboard navigation
Reduced Motion
All theme transition animations respect prefers-reduced-motion: reduce:
@media (prefers-reduced-motion: reduce) {
.theme-toggle .sun-icon,
.theme-toggle .moon-icon {
transition: none !important;
}
}
The icon swap still occurs — only the CSS transition animation is suppressed.
9. Performance Notes
Theme Initialization Cost
The blocking inline script adds ~0.1ms to initial HTML parse time. This is negligible compared to a FOUC (visible flash), which would negatively impact Cumulative Layout Shift (CLS) and user experience.
CSS Custom Properties
CSS custom properties are resolved at paint time by the browser. Having two sets of tokens (:root and html.dark) doesn't duplicate CSS rules — the same property names are used, just with different values. This is a standard, well-optimized browser pattern.
No JavaScript Re-renders
Theme switching updates a single class on <html>, which causes a CSS repaint. There is no JavaScript component tree re-render. The entire site can theme-switch in a single frame.
localStorage Access
The localStorage.getItem('theme') call in the blocking script is synchronous and fast (~0.01ms). It's wrapped in a try/catch to handle private browsing mode without errors.
Bundle Impact
The theme system adds:
design-tokens.css: ~8KB (unminified), ~3KB gzippedThemeToggle.astroinline script: ~1.5KB- BaseLayout blocking script: ~200 bytes
Total theme system overhead: ~5KB gzipped — minimal.
Generated by documentation-writer agent on 2026-03-21. Update this guide when making changes to the theme system.