Mode-Toggle
Dark/Light Mode Standard
This document defines the single source of truth, utilities, component patterns, and testing practices for dark/light mode across the site.
Source of Truth
- The authoritative state is on the
<html>element:data-mode="light"ordata-mode="dark".darkclass mirrors the same state for Tailwind v4 compatibility
- All mode-dependent CSS must target
html[data-mode="dark"]and/orhtml.dark. - Do not rely on
prefers-color-schemeinside components. The system preference is used only to set an initial mode when no user preference is stored.
Utilities
mode-switcher.js
- File:
src/utils/mode-switcher.js
Requirements
- On construction and on DOM ready, apply the current mode to
<html>. - Always set
data-modeto an explicit value ("light" or "dark"). Never remove it. - Keep
.darkclass exactly in sync with the mode. - Persist user choice in
localStorageunder keymode. - Dispatch a
mode-changeevent with detail{ mode: 'light' | 'dark' }.
Recommended implementation pattern
js
export class ModeSwitcher {
constructor() {
this.currentMode = this.getStoredMode() || this.getSystemPreference();
this.applyMode(this.currentMode, true);
}
getStoredMode() {
if (typeof window !== 'undefined') return localStorage.getItem('mode');
return null;
}
getSystemPreference() {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
storeMode(mode) {
if (typeof window !== 'undefined') localStorage.setItem('mode', mode);
}
applyMode(mode, initialLoad = false) {
if (typeof document === 'undefined') return;
const html = document.documentElement;
// Explicitly set data-mode to either 'light' or 'dark'
html.setAttribute('data-mode', mode);
html.classList.toggle('dark', mode === 'dark');
if (!initialLoad) {
this.currentMode = mode;
this.storeMode(mode);
}
this.dispatchModeChange(mode);
}
dispatchModeChange(mode) {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mode-change', { detail: { mode } }));
}
}
toggleMode() {
const newMode = this.currentMode === 'light' ? 'dark' : 'light';
this.applyMode(newMode);
return newMode;
}
setMode(mode) {
if (mode === 'light' || mode === 'dark') {
this.applyMode(mode);
return mode;
}
console.warn('Invalid mode:', mode);
return this.currentMode;
}
getCurrentMode() { return this.currentMode; }
}
// Global instance
export const modeSwitcher = new ModeSwitcher();
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
modeSwitcher.applyMode(modeSwitcher.getStoredMode() || modeSwitcher.getSystemPreference());
});
}
if (typeof window !== 'undefined') {
window.modeSwitcher = window.modeSwitcher || modeSwitcher;
} Early Mode Application (FOUC Prevention)
- File:
src/layouts/BoilerPlateHTML.astro - Include an inline script before the module import to apply mode ASAP based on
localStorageor system preference when no preference is stored. - This script must set
data-modeand.darkon<html>pre-paint. - Example (already in place):
html
<script is:inline>
(function() {
const savedMode = localStorage.getItem('mode');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const mode = savedMode || (systemPrefersDark ? 'dark' : 'light');
const html = document.documentElement;
// Explicitly set data-mode to either 'light' or 'dark'
html.setAttribute('data-mode', mode);
html.classList.toggle('dark', mode === 'dark');
// Only react to system changes if no stored preference exists
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('mode')) {
const newMode = e.matches ? 'dark' : 'light';
html.setAttribute('data-mode', newMode);
html.classList.toggle('dark', newMode === 'dark');
}
});
})();
</script>
<script type="module">
import '/src/utils/mode-switcher.js';
</script> ModeToggle Component Pattern
- File:
src/components/ui/ModeToggle.astro
Standards
- Two static icons/images in the markup; visibility is controlled by global CSS.
- In light mode, show the moon (indicates you can switch to dark).
- In dark mode, show the sun (indicates you can switch to light).
- Button should inherit color via
text-foreground. - On click, call
window.modeSwitcher.toggleMode()and updatearia-pressed,aria-label,title. - Listen for
mode-changeto sync all toggles.
CSS (global)
css
/* Default (light): show moon, hide sun */
.sun-icon { display: none; }
.moon-icon { display: block; }
/* Dark mode visibility */
html[data-mode="dark"] .sun-icon { display: block; }
html[data-mode="dark"] .moon-icon { display: none; }
/* Also support .dark */
html.dark .sun-icon { display: block; }
html.dark .moon-icon { display: none; } Script essentials
- Initialize from
modeSwitcher.getStoredMode() || modeSwitcher.getSystemPreference() - Update
aria-pressed,aria-label,title - Delegate click to
modeSwitcher.toggleMode() - Sync on
mode-change
Theming Components Pattern
ThemeImage
- File:
src/components/ui/ThemeImage.astro - Provide both light and dark
<img>. - Use global selectors to toggle:
css
/* Default */
.light-image { display: block; }
.dark-image { display: none; }
/* Dark mode */
html[data-mode="dark"] .light-image { display: none; }
html[data-mode="dark"] .dark-image { display: block; }
/* Tailwind dark class support */
html.dark .light-image { display: none; }
html.dark .dark-image { display: block; } - Do not use Tailwind
dark:utilities orprefers-color-schemehere. - Sizing: the container controls size; images are
object-containand fill container as needed.
SiteBrandMarkModeWrapper
- File:
src/components/ui/SiteBrandMarkModeWrapper.astro - Apply
classNameto the wrapper viaclass:list(not on the<img>). - Use high-specificity global CSS for
.light-logoand.dark-logo. Keep!importantonly if conflicts persist; remove once stable:
css
.brand-mark-wrapper .light-logo { display: block !important; }
.brand-mark-wrapper .dark-logo { display: none !important; }
html[data-mode="dark"] .brand-mark-wrapper .light-logo { display: none !important; }
html[data-mode="dark"] .brand-mark-wrapper .dark-logo { display: block !important; }
html.dark .brand-mark-wrapper .light-logo { display: none !important; }
html.dark .brand-mark-wrapper .dark-logo { display: block !important; } Global CSS Rules
- All dark/light swaps must be in
<style is:global>blocks to avoid Astro style scoping conflicts. - Only target
html[data-mode="dark"]andhtml.darkas selectors. - Avoid component-level
@media (prefers-color-scheme)fallback. The global early script and mode-switcher manage system preference.
Accessibility
- Toggle buttons must update:
aria-pressedto reflect dark mode boolean.aria-labelandtitleto "Switch to light/dark mode" accordingly.
- Ensure icons have
aria-hidden="true"and actionable labels are on the button only.
Performance
- Two-image pattern is acceptable for small logos/icons. For large media, consider
<picture>with media queries if needed, but still control visibility via the samehtml[data-mode]pattern for consistency. - Prevent FOUC with early inline script as shown.
Testing and QA Checklist
- Header logo swaps correctly on click of ModeToggle.
- Footer logo swaps correctly.
- Toggle shows moon in light mode, sun in dark mode.
document.documentElementcontains bothdata-modeand.darkin sync at all times.- Hard refresh: initial mode follows stored preference; otherwise follows system preference.
- System preference change while no stored preference exists updates mode live.
mode-changeevent fires withdetail.modeand other components respond (e.g., toggles update aria attributes).
Maintenance Rules
- When creating any new component that varies by mode:
- Add both light and dark elements in the DOM.
- Add a global CSS block with the standard selectors.
- Do not use Tailwind
dark:orprefers-color-schemein the component. - Ensure container sizing controls the asset scale; avoid forcing
w-fullh-fullunless container is correct.
- When changing the mode-switcher:
- Keep
data-modeexplicitly set to "light" or "dark". - Keep
.darkclass in sync. - Keep the
mode-changeevent payload shape{ mode }stable.
By following this standard, the ModeToggle and all theme-aware components will switch reliably and consistently across the entire site without React/JSX or hydration overhead.