
Dark Mode Design Expert
- 214 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Design readable dark themes with elevation, borders, and state colors that respect OLED, contrast, and user preference toggles.
About
Specializes in dark UI systems: surface hierarchy, muted text ramps, accent discipline, accessibility, and implementation-ready tokens for web and mobile dark themes without muddy contrast.
- Surface elevation rules
- Reduced halation palettes
- Focus and error states
- System vs manual toggle
- Token migration tips
Dark Mode Design Expert by the numbers
- 214 all-time installs (skills.sh)
- Ranked #925 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill dark-mode-design-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 214 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Design readable dark themes with elevation, borders, and state colors that respect OLED, contrast, and user preference toggles.
Files
Dark Mode Design Expert
Master dark mode UI design with atmospheric theming, WCAG accessibility, and cross-platform best practices. Specializes in weather/sky/ocean-inspired color systems that adapt to time of day and environmental conditions.
When to Use This Skill
Activate on:
- "dark mode", "dark theme", "night mode"
- "theme switching", "light/dark toggle"
- "atmospheric UI", "weather theme", "sky gradient"
- "OLED optimization", "battery-friendly dark"
- "elevation in dark mode", "surface layering"
- "prefers-color-scheme", "color-scheme CSS"
- "contrast ratios dark mode", "accessibility dark theme"
NOT for:
- General color palette creation →
color-theory-palette-harmony-expert - Typography and font selection →
typography-expert - Component library architecture →
design-system-creator - Contrast auditing of specific colors →
color-contrast-auditor
---
The Science of Dark Mode
Why Dark Mode Exists
| Factor | Light Mode | Dark Mode | Winner |
|---|---|---|---|
| OLED Battery | 100% baseline | 39-47% savings at max brightness | Dark |
| Low Light Comfort | Eye strain, fatigue | Reduced glare | Dark |
| Bright Environment | Better readability | Washed out | Light |
| Astigmatism Users | Easier to read | Halation effect | Light |
| Focus/Immersion | Standard | Content pops forward | Dark |
| Sleep Hygiene | Blue light exposure | Reduced blue light | Dark |
Key Insight: Dark mode isn't universally better—it's contextually better. The best systems respect user preference AND adapt to environment.
Contrast Requirements (WCAG 2.1)
| Element Type | Minimum Ratio | Target Ratio | Notes |
|---|---|---|---|
| Body text | 4.5:1 | 7:1+ | AAA preferred for readability |
| Large text (≥24px) | 3:1 | 4.5:1+ | Headlines, hero text |
| UI components | 3:1 | 4.5:1+ | Borders, icons, focus rings |
| Disabled elements | None required | 2.5:1 | UX consideration |
| Decorative | None required | - | Pure aesthetic elements |
Dark Mode Gotcha: High contrast (21:1 pure white on black) causes more eye strain than moderate contrast (15:1). Target 12:1 to 16:1 for primary text.
---
The Three-Tier Token Architecture
Foundation: Primitives → Semantic → Component
/* ══════════════════════════════════════════════════════════════════
TIER 1: PRIMITIVES - Raw color values, never used directly
══════════════════════════════════════════════════════════════════ */
:root {
/* Neutrals */
--color-gray-50: #f8fafc;
--color-gray-100: #f1f5f9;
--color-gray-200: #e2e8f0;
--color-gray-300: #cbd5e1;
--color-gray-400: #94a3b8;
--color-gray-500: #64748b;
--color-gray-600: #475569;
--color-gray-700: #334155;
--color-gray-800: #1e293b;
--color-gray-900: #0f172a;
--color-gray-950: #020617;
/* Brand Colors */
--color-ocean-300: #7dd3fc;
--color-ocean-400: #38bdf8;
--color-ocean-500: #0ea5e9;
--color-ocean-600: #0284c7;
--color-ocean-700: #0369a1;
/* Atmospheric Colors (for weather theming) */
--color-twilight-deep: #0c1222;
--color-twilight-mid: #151b2e;
--color-twilight-surface: #1a1f3a;
--color-dawn-warm: #fef3c7;
--color-sunset-orange: #fb923c;
--color-storm-gray: #374151;
}
/* ══════════════════════════════════════════════════════════════════
TIER 2: SEMANTIC - Purpose-driven, theme-aware
══════════════════════════════════════════════════════════════════ */
/* Light Mode (Default) */
:root, :root.theme-light {
/* Text */
--color-text-primary: var(--color-gray-900); /* 15.3:1 on white */
--color-text-secondary: var(--color-gray-600); /* 7.0:1 on white */
--color-text-muted: var(--color-gray-500); /* 4.6:1 on white */
--color-text-inverse: var(--color-gray-50);
/* Backgrounds */
--color-bg-primary: #ffffff;
--color-bg-secondary: var(--color-gray-50);
--color-bg-elevated: #ffffff;
--color-bg-overlay: rgba(0, 0, 0, 0.5);
/* Surfaces (elevation system) */
--color-surface-base: #ffffff;
--color-surface-raised: #ffffff;
--color-surface-overlay: #ffffff;
/* Borders */
--color-border-default: var(--color-gray-200);
--color-border-muted: var(--color-gray-100);
--color-border-emphasis: var(--color-gray-300);
/* Interactive */
--color-interactive-primary: var(--color-ocean-600);
--color-interactive-hover: var(--color-ocean-700);
--color-interactive-focus: var(--color-ocean-500);
/* Elevation (shadows work in light mode) */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
}
/* Dark Mode */
:root.theme-dark {
/* Text - slightly off-white to reduce strain */
--color-text-primary: var(--color-gray-50); /* 15.3:1 on dark */
--color-text-secondary: var(--color-gray-300); /* 9.3:1 on dark */
--color-text-muted: var(--color-gray-400); /* 5.5:1 on dark */
--color-text-inverse: var(--color-gray-900);
/* Backgrounds - NOT pure black (#000) */
--color-bg-primary: var(--color-twilight-deep); /* #0c1222 */
--color-bg-secondary: var(--color-twilight-mid); /* #151b2e */
--color-bg-elevated: var(--color-twilight-surface); /* #1a1f3a */
--color-bg-overlay: rgba(0, 0, 0, 0.7);
/* Surfaces - LIGHTER for elevation (key dark mode principle) */
--color-surface-base: var(--color-twilight-deep);
--color-surface-raised: var(--color-twilight-mid);
--color-surface-overlay: var(--color-twilight-surface);
/* Borders - more visible in dark mode */
--color-border-default: rgba(255, 255, 255, 0.1);
--color-border-muted: rgba(255, 255, 255, 0.05);
--color-border-emphasis: rgba(255, 255, 255, 0.2);
/* Interactive - brighter for visibility */
--color-interactive-primary: var(--color-ocean-400);
--color-interactive-hover: var(--color-ocean-300);
--color-interactive-focus: var(--color-ocean-500);
/* Elevation - GLOW replaces shadows in dark mode */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.6);
/* Glow effects (unique to dark mode) */
--glow-sm: 0 0 8px rgba(56, 189, 248, 0.2);
--glow-md: 0 0 16px rgba(56, 189, 248, 0.3);
--glow-lg: 0 0 32px rgba(56, 189, 248, 0.4);
}
/* ══════════════════════════════════════════════════════════════════
TIER 3: COMPONENT - Specific usage, consuming semantic tokens
══════════════════════════════════════════════════════════════════ */
:root {
/* Buttons */
--button-bg: var(--color-interactive-primary);
--button-text: var(--color-text-inverse);
--button-border: transparent;
--button-shadow: var(--shadow-sm);
/* Cards */
--card-bg: var(--color-surface-raised);
--card-border: var(--color-border-default);
--card-shadow: var(--shadow-md);
/* Inputs */
--input-bg: var(--color-bg-primary);
--input-border: var(--color-border-default);
--input-focus-ring: var(--color-interactive-focus);
}---
Elevation in Dark Mode: The Critical Difference
Why Shadows Fail in Dark Mode
In light mode, shadows create depth by simulating light from above. In dark mode:
- Shadows become invisible against dark backgrounds
- Pure black shadows look like "holes"
- The illusion breaks completely
Material Design 3 Solution: Tonal Elevation
Instead of shadows, use lighter surface colors for elevated elements:
/* Light Mode: Shadows create elevation */
.card-light {
background: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Dark Mode: Surface color creates elevation */
.card-dark {
background: #1e1e1e; /* Elevated from #121212 base */
box-shadow: none; /* Or very subtle */
}Elevation Scale (Material Design 3)
| Level | Light Mode | Dark Mode Surface | Overlay % |
|---|---|---|---|
| 0 (base) | #ffffff | #121212 | 0% |
| 1 | shadow-sm | #1e1e1e | 5% white |
| 2 | shadow-md | #232323 | 7% white |
| 3 | shadow-lg | #282828 | 8% white |
| 4 | shadow-xl | #2d2d2d | 9% white |
| 5 | shadow-2xl | #323232 | 11% white |
Implementation Pattern
:root.theme-dark {
/* Calculate overlay colors */
--elevation-1: color-mix(in srgb, white 5%, var(--color-bg-primary));
--elevation-2: color-mix(in srgb, white 7%, var(--color-bg-primary));
--elevation-3: color-mix(in srgb, white 8%, var(--color-bg-primary));
--elevation-4: color-mix(in srgb, white 9%, var(--color-bg-primary));
--elevation-5: color-mix(in srgb, white 11%, var(--color-bg-primary));
}
.card {
background: var(--elevation-2);
}
.modal {
background: var(--elevation-4);
}
.dropdown {
background: var(--elevation-3);
}---
CSS Implementation Patterns
Modern Approach: prefers-color-scheme + light-dark()
/* 1. Set color-scheme for native element styling */
:root {
color-scheme: light dark;
}
/* 2. Use light-dark() for inline theming (2024+ browsers) */
.card {
background: light-dark(#ffffff, #1e1e1e);
color: light-dark(#1f2937, #f3f4f6);
border: 1px solid light-dark(#e5e7eb, rgba(255,255,255,0.1));
}
/* 3. Respect system preference */
@media (prefers-color-scheme: dark) {
:root:not(.theme-light) {
/* Dark mode tokens */
}
}
@media (prefers-color-scheme: light) {
:root:not(.theme-dark) {
/* Light mode tokens */
}
}Theme Switching with JavaScript
// Theme manager with persistence
type Theme = 'light' | 'dark' | 'system';
function setTheme(theme: Theme) {
const root = document.documentElement;
// Remove existing theme classes
root.classList.remove('theme-light', 'theme-dark');
if (theme === 'system') {
// Let CSS media queries handle it
localStorage.removeItem('theme');
return;
}
// Apply explicit theme
root.classList.add(`theme-${theme}`);
localStorage.setItem('theme', theme);
}
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
// Initialize on page load (before render to prevent flash)
function initTheme() {
const saved = localStorage.getItem('theme') as Theme | null;
if (saved && saved !== 'system') {
document.documentElement.classList.add(`theme-${saved}`);
}
}
// Listen for system changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
// Only react if user hasn't set explicit preference
// CSS will handle via media queries
}
});Preventing Flash of Wrong Theme (FOWT)
<!-- In <head>, before any CSS -->
<script>
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('theme-dark');
} else if (theme === 'light') {
document.documentElement.classList.add('theme-light');
}
})();
</script>---
Worked Example: Weather/Sky/Ocean Atmospheric UI
This is the flagship example—a complete token system for a weather-inspired UI that adapts to time of day and atmospheric conditions.
Design Philosophy
The ocean and sky share a visual language:
- Dawn/Dusk: Warm gradients, soft transitions
- Midday: Bright, high contrast, clear
- Night: Deep blues, subtle glows, stars
- Storm: Dramatic grays, electric highlights
- Underwater: Teal depths, bioluminescent accents
Complete Token System
/* ══════════════════════════════════════════════════════════════════
OCEAN MODERN: ATMOSPHERIC UI TOKEN SYSTEM
A weather/sky/ocean-inspired design system with time-of-day awareness
══════════════════════════════════════════════════════════════════ */
:root {
/* ────────────────────────────────────────────────────────────────
PRIMITIVES: Atmospheric Color Palette
──────────────────────────────────────────────────────────────── */
/* Ocean Depths */
--ocean-surface: #38bdf8; /* Sunlit surface */
--ocean-shallow: #0ea5e9; /* Clear shallows */
--ocean-mid: #0284c7; /* Mid-depth */
--ocean-deep: #0369a1; /* Deep water */
--ocean-abyss: #075985; /* Abyssal zone */
--ocean-trench: #0c4a6e; /* Hadal zone */
/* Sky States */
--sky-dawn: #fef3c7; /* Golden hour */
--sky-morning: #bae6fd; /* Clear morning */
--sky-midday: #7dd3fc; /* Bright day */
--sky-golden: #fcd34d; /* Golden hour */
--sky-sunset: #fb923c; /* Sunset orange */
--sky-dusk: #a78bfa; /* Purple dusk */
--sky-twilight: #6366f1; /* Civil twilight */
--sky-night: #1e1b4b; /* Night sky */
/* Atmospheric Effects */
--atmosphere-haze: rgba(186, 230, 253, 0.3);
--atmosphere-fog: rgba(241, 245, 249, 0.6);
--atmosphere-mist: rgba(148, 163, 184, 0.4);
/* Storm System */
--storm-light: #9ca3af;
--storm-mid: #6b7280;
--storm-dark: #4b5563;
--storm-thunder: #374151;
--storm-lightning: #fbbf24;
/* Bioluminescence (dark mode accents) */
--bio-cyan: #22d3ee;
--bio-teal: #2dd4bf;
--bio-blue: #60a5fa;
--bio-purple: #a78bfa;
--bio-glow: rgba(34, 211, 238, 0.4);
/* Sand & Beach */
--sand-light: #fef3c7;
--sand-warm: #fde68a;
--sand-golden: #fcd34d;
--sand-wet: #d4a574;
/* Coral & Life */
--coral-pink: #fb7185;
--coral-orange: #fb923c;
--kelp-green: #22c55e;
--algae-teal: #14b8a6;
}
/* ════════════════════════════════════════════════════════════════════
SEMANTIC: Time-of-Day Themes
════════════════════════════════════════════════════════════════════ */
/* DAWN THEME (5am - 8am) - Warm, hopeful, transitional */
:root.atmosphere-dawn {
--color-text-primary: #1e293b;
--color-text-secondary: #475569;
--color-bg-primary: var(--sky-dawn);
--color-bg-secondary: #fef9e7;
--color-accent: var(--sky-golden);
--gradient-sky: linear-gradient(
180deg,
var(--sky-night) 0%,
var(--sky-dusk) 20%,
var(--sky-sunset) 40%,
var(--sky-golden) 70%,
var(--sky-dawn) 100%
);
--gradient-ocean: linear-gradient(
180deg,
var(--ocean-deep) 0%,
var(--ocean-mid) 50%,
var(--ocean-surface) 100%
);
}
/* DAYLIGHT THEME (8am - 5pm) - Bright, clear, energetic */
:root.atmosphere-day, :root.theme-light {
--color-text-primary: #0f172a;
--color-text-secondary: #334155;
--color-text-muted: #64748b;
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8fafc;
--color-bg-elevated: #ffffff;
--color-accent: var(--ocean-shallow);
--color-accent-hover: var(--ocean-mid);
--gradient-sky: linear-gradient(
180deg,
var(--sky-midday) 0%,
var(--sky-morning) 50%,
#ffffff 100%
);
--gradient-ocean: linear-gradient(
180deg,
var(--ocean-abyss) 0%,
var(--ocean-deep) 30%,
var(--ocean-mid) 60%,
var(--ocean-shallow) 100%
);
/* Daylight uses shadows for elevation */
--elevation-method: shadow;
}
/* GOLDEN HOUR THEME (5pm - 7pm) - Warm, dramatic, nostalgic */
:root.atmosphere-golden {
--color-text-primary: #1c1917;
--color-text-secondary: #44403c;
--color-bg-primary: #fffbeb;
--color-bg-secondary: #fef3c7;
--color-accent: var(--sky-sunset);
--gradient-sky: linear-gradient(
180deg,
var(--sky-midday) 0%,
var(--sky-golden) 40%,
var(--sky-sunset) 70%,
var(--coral-pink) 100%
);
--gradient-ocean: linear-gradient(
180deg,
var(--ocean-deep) 0%,
#0891b2 50%,
#fcd34d 100%
);
}
/* TWILIGHT THEME (7pm - 9pm) - Transitional, mysterious */
:root.atmosphere-twilight {
--color-text-primary: #e2e8f0;
--color-text-secondary: #94a3b8;
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-bg-elevated: #334155;
--color-accent: var(--sky-twilight);
--gradient-sky: linear-gradient(
180deg,
var(--sky-night) 0%,
var(--sky-twilight) 30%,
var(--sky-dusk) 60%,
var(--sky-sunset) 100%
);
--gradient-ocean: linear-gradient(
180deg,
var(--ocean-trench) 0%,
var(--ocean-abyss) 50%,
var(--ocean-deep) 100%
);
}
/* NIGHT THEME (9pm - 5am) - Deep, calm, bioluminescent */
:root.atmosphere-night, :root.theme-dark {
--color-text-primary: #f1f5f9; /* 15.3:1 ✓ AAA */
--color-text-secondary: #cbd5e1; /* 9.3:1 ✓ AAA */
--color-text-muted: #94a3b8; /* 5.5:1 ✓ AA */
--color-bg-primary: #0c1222; /* Deep twilight navy */
--color-bg-secondary: #151b2e;
--color-bg-elevated: #1a1f3a;
--color-accent: var(--bio-cyan);
--color-accent-hover: var(--bio-teal);
--gradient-sky: linear-gradient(
180deg,
#020617 0%,
var(--sky-night) 50%,
#1e1b4b 100%
);
--gradient-ocean: linear-gradient(
180deg,
#020617 0%,
var(--ocean-trench) 50%,
var(--ocean-abyss) 100%
);
/* Night uses lighter surfaces for elevation */
--elevation-method: surface;
/* Bioluminescent glow effects */
--glow-accent: 0 0 20px var(--bio-glow);
--glow-subtle: 0 0 10px rgba(34, 211, 238, 0.2);
/* Surface elevation scale */
--surface-base: #0c1222;
--surface-1: #111827;
--surface-2: #1f2937;
--surface-3: #374151;
--surface-4: #4b5563;
}
/* STORM THEME - Dramatic, intense, electric */
:root.atmosphere-storm {
--color-text-primary: #f3f4f6;
--color-text-secondary: #d1d5db;
--color-bg-primary: var(--storm-thunder);
--color-bg-secondary: var(--storm-dark);
--color-bg-elevated: var(--storm-mid);
--color-accent: var(--storm-lightning);
--gradient-sky: linear-gradient(
180deg,
var(--storm-thunder) 0%,
var(--storm-dark) 30%,
var(--storm-mid) 60%,
var(--storm-light) 100%
);
--gradient-ocean: linear-gradient(
180deg,
#1f2937 0%,
#374151 40%,
#6b7280 80%,
#9ca3af 100%
);
/* Lightning flash animation */
--flash-color: rgba(251, 191, 36, 0.3);
}
/* ════════════════════════════════════════════════════════════════════
COMPONENT: Atmospheric UI Elements
════════════════════════════════════════════════════════════════════ */
/* Glass Card - works in all atmospheres */
.glass-card {
background: var(--glass-bg, rgba(255, 255, 255, 0.1));
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.2));
border-radius: 16px;
}
:root.theme-light .glass-card,
:root.atmosphere-day .glass-card,
:root.atmosphere-dawn .glass-card {
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
}
:root.theme-dark .glass-card,
:root.atmosphere-night .glass-card,
:root.atmosphere-twilight .glass-card {
--glass-bg: rgba(15, 23, 42, 0.6);
--glass-border: rgba(255, 255, 255, 0.1);
box-shadow: var(--glow-subtle);
}
/* Wave Animation */
.wave-layer {
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: var(--wave-height, 100px);
background: var(--wave-color);
animation: wave var(--wave-duration, 8s) ease-in-out infinite;
opacity: var(--wave-opacity, 0.6);
}
@keyframes wave {
0%, 100% { transform: translateX(0) translateY(0); }
50% { transform: translateX(-25%) translateY(-10px); }
}
/* Bioluminescent Glow (dark mode only) */
:root.theme-dark .glow-element,
:root.atmosphere-night .glow-element {
box-shadow: var(--glow-accent);
transition: box-shadow 0.3s ease;
}
:root.theme-dark .glow-element:hover,
:root.atmosphere-night .glow-element:hover {
box-shadow: 0 0 30px var(--bio-glow), 0 0 60px rgba(34, 211, 238, 0.2);
}
/* Cloud Layer */
.cloud-layer {
position: absolute;
width: 100%;
height: 100%;
background-image: var(--cloud-pattern);
opacity: var(--cloud-opacity, 0.5);
animation: drift var(--cloud-speed, 60s) linear infinite;
}
@keyframes drift {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}Time-Based Theme Switching
type Atmosphere = 'dawn' | 'day' | 'golden' | 'twilight' | 'night' | 'storm';
function getAtmosphereFromTime(hour: number): Atmosphere {
if (hour >= 5 && hour < 8) return 'dawn';
if (hour >= 8 && hour < 17) return 'day';
if (hour >= 17 && hour < 19) return 'golden';
if (hour >= 19 && hour < 21) return 'twilight';
return 'night';
}
function setAtmosphere(atmosphere: Atmosphere) {
const root = document.documentElement;
// Remove all atmosphere classes
root.classList.remove(
'atmosphere-dawn', 'atmosphere-day', 'atmosphere-golden',
'atmosphere-twilight', 'atmosphere-night', 'atmosphere-storm',
'theme-light', 'theme-dark'
);
// Add new atmosphere
root.classList.add(`atmosphere-${atmosphere}`);
// Also set base theme for compatibility
const isDark = ['twilight', 'night', 'storm'].includes(atmosphere);
root.classList.add(isDark ? 'theme-dark' : 'theme-light');
}
// Auto-update based on time
function initAtmosphericUI() {
const updateAtmosphere = () => {
const hour = new Date().getHours();
setAtmosphere(getAtmosphereFromTime(hour));
};
updateAtmosphere();
// Update every 15 minutes
setInterval(updateAtmosphere, 15 * 60 * 1000);
}Weather API Integration
interface WeatherCondition {
main: 'Clear' | 'Clouds' | 'Rain' | 'Thunderstorm' | 'Snow' | 'Mist';
description: string;
}
function getAtmosphereFromWeather(
weather: WeatherCondition,
hour: number
): Atmosphere {
// Storm conditions override time
if (weather.main === 'Thunderstorm') return 'storm';
// Heavy overcast dims everything
if (weather.main === 'Clouds' && weather.description.includes('overcast')) {
return hour >= 19 || hour < 6 ? 'night' : 'twilight';
}
// Default to time-based
return getAtmosphereFromTime(hour);
}---
Anti-Patterns to Avoid
1. Pure Black Background (#000000)
Problem: Causes eye strain, harsh contrast, OLED "smearing" on scroll Solution: Use near-black like #0c1222, #121212, or #1a1a2e
2. Pure White Text (#FFFFFF) on Dark
Problem: Too harsh, causes halation for astigmatism users Solution: Use off-white like #f1f5f9, #e2e8f0
3. Same Colors for Both Themes
Problem: Teal that looks great on white becomes invisible on dark Solution: Use brighter variants in dark mode (ocean-400 instead of ocean-600)
4. Shadows in Dark Mode
Problem: Shadows disappear against dark backgrounds Solution: Use lighter surface colors for elevation instead
5. Inverted Light Mode Colors
Problem: Simply inverting creates jarring, unnatural results Solution: Design dark mode as its own coherent system
6. Ignoring System Preference
Problem: Forcing dark mode ignores user's system-wide preference Solution: Default to prefers-color-scheme, allow override
7. Flash of Wrong Theme
Problem: Page loads light, then flashes to dark Solution: Inline script in <head> before CSS loads
---
Testing Checklist
Visual Testing
- [ ] Primary text readable on all backgrounds (4.5:1+)
- [ ] Secondary text readable (4.5:1+)
- [ ] Muted text acceptable (3:1+ for large, 4.5:1+ for normal)
- [ ] Interactive elements distinguishable
- [ ] Focus states clearly visible
- [ ] Disabled states identifiable (but not required contrast)
- [ ] Elevation hierarchy clear without shadows
- [ ] No harsh white/black combinations
Functional Testing
- [ ] Theme toggle works correctly
- [ ] System preference respected on first load
- [ ] Theme persists across page reloads
- [ ] No flash of wrong theme
- [ ] Images adapt appropriately
- [ ] Code blocks readable
- [ ] Charts/graphs remain legible
- [ ] Form elements properly styled
Device Testing
- [ ] OLED screens (check for smearing on scroll)
- [ ] LCD screens (check for backlight bleed visibility)
- [ ] High brightness outdoor use
- [ ] Low brightness night use
- [ ] Color blindness simulation
---
Industry References
Material Design 3
- Base dark surface: #121212
- Tonal elevation with white overlay (5-11%)
- Primary colors at 80% lightness for dark mode
- 15.8:1 target contrast for elevated surfaces
Apple Human Interface Guidelines
- Respect system appearance setting
- Semantic colors that adapt automatically
- Increased vibrancy in dark mode
- Base system background: dynamic (not static black)
Figma
- Background: #2c2c2c (base), #383838 (elevated)
- Text: #ffffff (primary), #b3b3b3 (secondary)
- Accent: #0d99ff (brand blue, brightened for dark)
Discord
- Background: #36393f (main), #2f3136 (sidebar)
- Text: #dcddde (primary), #72767d (muted)
- Accent: #5865f2 (blurple, same in both modes)
Slack
- Background: #1a1d21 (base), #222529 (elevated)
- Uses colored sidebars in dark mode
- Maintains brand identity while adapting
---
Companion Skills
| Skill | Handoff Point |
|---|---|
color-contrast-auditor | After designing tokens, audit specific pairs |
design-system-creator | Integrate dark mode into broader design system |
web-design-expert | Overall visual direction and brand alignment |
color-theory-palette-harmony-expert | Generating initial color palettes |
---
Remember: Dark mode isn't the absence of light—it's the careful orchestration of luminance to guide attention, reduce strain, and create atmosphere.
CSS Dark Mode Implementation Patterns
The Modern Stack (2024-2025)
1. color-scheme Property
Tells the browser which color schemes your page supports:
/* Support both */
:root {
color-scheme: light dark;
}
/* This enables native styling for:
- Scrollbars
- Form controls
- Selection highlighting
- System colors
*/2. prefers-color-scheme Media Query
Detect user's system preference:
/* Light mode (explicit) */
@media (prefers-color-scheme: light) {
:root {
--bg: #ffffff;
--text: #1f2937;
}
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c1222;
--text: #f1f5f9;
}
}3. light-dark() Function (CSS Color Level 5)
New in 2024 - inline theme switching:
:root {
color-scheme: light dark;
}
.card {
/* First value for light, second for dark */
background: light-dark(#ffffff, #1e1e1e);
color: light-dark(#1f2937, #f3f4f6);
border: 1px solid light-dark(#e5e7eb, rgba(255,255,255,0.1));
}Browser Support (Jan 2025): Chrome 123+, Firefox 120+, Safari 17.5+
Implementation Patterns
Pattern A: CSS Custom Properties (Recommended)
/* Base tokens - light mode default */
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8fafc;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-border: #e2e8f0;
--color-accent: #0284c7;
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Dark mode overrides */
@media (prefers-color-scheme: dark) {
:root:not(.theme-light) {
--color-bg-primary: #0c1222;
--color-bg-secondary: #151b2e;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: rgba(255, 255, 255, 0.1);
--color-accent: #38bdf8;
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
}
}
/* Explicit dark mode class (overrides system) */
:root.theme-dark {
--color-bg-primary: #0c1222;
/* ... same as above ... */
}
/* Explicit light mode class (overrides system) */
:root.theme-light {
--color-bg-primary: #ffffff;
/* ... same as above ... */
}
/* Components consume semantic tokens */
.card {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
}Pattern B: Data Attribute Selector
/* Using data attribute for theme state */
[data-theme="light"] {
--bg: #ffffff;
--text: #1f2937;
}
[data-theme="dark"] {
--bg: #0c1222;
--text: #f1f5f9;
}
/* System preference fallback */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0c1222;
--text: #f1f5f9;
}
}// Toggle theme
document.documentElement.dataset.theme = 'dark';Pattern C: Class-Based (Simple)
.theme-light {
--bg: #ffffff;
--text: #1f2937;
}
.theme-dark {
--bg: #0c1222;
--text: #f1f5f9;
}Preventing Flash of Wrong Theme (FOWT)
The Problem
1. Browser requests page
2. HTML loads with no theme class
3. CSS loads, applies light mode (default)
4. JavaScript runs, reads localStorage, applies dark class
5. Page flashes from light to dark ← BAD UXSolution: Blocking Script in <head>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- BLOCKING: Runs before CSS renders -->
<script>
(function() {
// Check localStorage first
const saved = localStorage.getItem('theme');
if (saved === 'dark') {
document.documentElement.classList.add('theme-dark');
} else if (saved === 'light') {
document.documentElement.classList.add('theme-light');
}
// If no saved preference, CSS media queries handle it
})();
</script>
<!-- CSS loads after theme class is set -->
<link rel="stylesheet" href="styles.css">
</head>Alternative: CSS-Only with @import
/* Import theme based on system preference */
@import url('light.css') (prefers-color-scheme: light);
@import url('dark.css') (prefers-color-scheme: dark);Theme Switching JavaScript
Complete Implementation
type Theme = 'light' | 'dark' | 'system';
class ThemeManager {
private static STORAGE_KEY = 'theme';
static init() {
// Apply saved theme immediately (called from head script)
const saved = localStorage.getItem(this.STORAGE_KEY) as Theme | null;
if (saved && saved !== 'system') {
document.documentElement.classList.add(`theme-${saved}`);
}
// Listen for system changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem(this.STORAGE_KEY)) {
// Only react if user hasn't set explicit preference
this.updateMetaThemeColor(e.matches ? 'dark' : 'light');
}
});
}
static setTheme(theme: Theme) {
const root = document.documentElement;
root.classList.remove('theme-light', 'theme-dark');
if (theme === 'system') {
localStorage.removeItem(this.STORAGE_KEY);
} else {
root.classList.add(`theme-${theme}`);
localStorage.setItem(this.STORAGE_KEY, theme);
}
this.updateMetaThemeColor(this.getEffectiveTheme());
}
static getTheme(): Theme {
return (localStorage.getItem(this.STORAGE_KEY) as Theme) || 'system';
}
static getEffectiveTheme(): 'light' | 'dark' {
const saved = this.getTheme();
if (saved !== 'system') return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
private static updateMetaThemeColor(theme: 'light' | 'dark') {
const color = theme === 'dark' ? '#0c1222' : '#ffffff';
let meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
}
meta.setAttribute('content', color);
}
}React Hook
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
useEffect(() => {
const root = document.documentElement;
root.classList.remove('theme-light', 'theme-dark');
if (theme === 'system') {
localStorage.removeItem('theme');
setResolvedTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
);
} else {
root.classList.add(`theme-${theme}`);
localStorage.setItem('theme', theme);
setResolvedTheme(theme);
}
}, [theme]);
// Listen for system preference changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (theme === 'system') {
setResolvedTheme(e.matches ? 'dark' : 'light');
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme]);
return {
theme,
resolvedTheme,
setTheme: setThemeState,
isDark: resolvedTheme === 'dark',
};
}Image Handling
Approach 1: CSS Filters
/* Dim images slightly in dark mode */
:root.theme-dark img:not([data-no-dim]) {
filter: brightness(0.9);
}
/* Invert diagrams/icons that are black on white */
:root.theme-dark img[data-invert] {
filter: invert(1) hue-rotate(180deg);
}Approach 2: Picture Element
<picture>
<source srcset="hero-dark.jpg" media="(prefers-color-scheme: dark)">
<img src="hero-light.jpg" alt="Hero image">
</picture>Approach 3: CSS Background
.hero {
background-image: url('hero-light.jpg');
}
@media (prefers-color-scheme: dark) {
.hero {
background-image: url('hero-dark.jpg');
}
}Form Element Styling
/* Reset form elements to use theme colors */
:root.theme-dark {
color-scheme: dark;
}
/* Custom form styling */
input, textarea, select {
background: var(--color-bg-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
input::placeholder {
color: var(--color-text-muted);
}
/* Focus ring */
input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.2);
}Debugging Tips
Chrome DevTools
1. Emulate `prefers-color-scheme`:
- Open DevTools → Rendering tab → Emulate CSS media feature
2. See computed values:
- Inspect element → Computed tab → Filter by CSS variable
Testing Checklist
// Test all theme states
const testThemes = () => {
['light', 'dark', 'system'].forEach(theme => {
ThemeManager.setTheme(theme);
console.log(`Theme: ${theme}, Effective: ${ThemeManager.getEffectiveTheme()}`);
// Visual inspection or screenshot
});
};Elevation Strategies in Dark Mode
The Core Problem
In light mode, shadows create visual hierarchy by simulating a light source from above. This breaks completely in dark mode:
LIGHT MODE DARK MODE
┌─────────────────┐ ┌─────────────────┐
│ Card │ │ Card │
│ │ │ │
└─────────────────┘ └─────────────────┘
████████ (shadow visible) (shadow invisible!)Strategy 1: Surface Color Elevation (Recommended)
Material Design 3's approach: elevated surfaces are lighter, not shadowed.
:root.theme-dark {
/* Each elevation level is progressively lighter */
--surface-0: #121212; /* Base */
--surface-1: #1e1e1e; /* +5% white */
--surface-2: #232323; /* +7% white */
--surface-3: #252525; /* +8% white */
--surface-4: #272727; /* +9% white */
--surface-5: #2c2c2c; /* +11% white */
}
/* Modal (highest elevation) */
.modal {
background: var(--surface-5);
}
/* Dropdown (medium elevation) */
.dropdown {
background: var(--surface-3);
}
/* Card (low elevation) */
.card {
background: var(--surface-1);
}
/* Page (base level) */
body {
background: var(--surface-0);
}Calculating Overlay Percentages
function calculateElevation(baseColor, level) {
const overlayPercentages = [0, 5, 7, 8, 9, 11, 12, 14, 15, 16];
const overlay = overlayPercentages[level] / 100;
// Mix white into base color
return mixColors(baseColor, '#ffffff', overlay);
}Strategy 2: Border Definition
Use subtle borders to define boundaries instead of shadows.
:root.theme-dark {
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.1);
--border-emphasis: rgba(255, 255, 255, 0.16);
}
.card {
background: var(--surface-1);
border: 1px solid var(--border-default);
}
.card:hover {
border-color: var(--border-emphasis);
}Strategy 3: Glow Effects (For Accents)
Replace drop shadows with ambient glow for interactive elements.
.button-primary {
background: var(--color-accent);
/* Replace shadow with glow */
box-shadow: 0 0 0 0 transparent;
transition: box-shadow 0.2s ease;
}
.button-primary:hover {
box-shadow: 0 0 20px rgba(56, 189, 248, 0.4);
}
.button-primary:focus {
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.5);
}Strategy 4: Hybrid Approach
Combine strategies based on context.
:root.theme-dark {
/* Modals get surface elevation + border + subtle shadow */
--modal-bg: var(--surface-4);
--modal-border: var(--border-default);
--modal-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
/* Cards get surface elevation + border, no shadow */
--card-bg: var(--surface-1);
--card-border: var(--border-subtle);
--card-shadow: none;
/* Buttons get glow on interaction */
--button-shadow-hover: 0 0 20px var(--glow-color);
}Z-Index Correlation
Higher z-index should correlate with higher surface elevation:
| Element | Z-Index | Surface Level | Border |
|---|---|---|---|
| Page | 0 | 0 | none |
| Card | 1 | 1 | subtle |
| Sticky header | 10 | 2 | default |
| Dropdown | 20 | 3 | default |
| Modal backdrop | 40 | - | - |
| Modal | 50 | 5 | emphasis |
| Toast | 60 | 5 | emphasis |
| Tooltip | 70 | 4 | default |
Animation Considerations
/* Surface elevation changes should animate */
.card {
background: var(--surface-1);
transition: background 0.2s ease;
}
.card:hover {
background: var(--surface-2);
}
/* Glow should animate smoothly */
.interactive {
box-shadow: 0 0 0 0 transparent;
transition: box-shadow 0.3s ease;
}
.interactive:focus {
box-shadow: 0 0 16px var(--glow-color);
}Common Mistakes
Mistake 1: Using Light Mode Shadows
/* DON'T: Same shadow in dark mode */
.card {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* DO: Dark mode uses elevation */
:root.theme-dark .card {
background: var(--surface-1);
box-shadow: none;
}Mistake 2: Flat Design (No Hierarchy)
/* DON'T: Everything same surface */
.card, .modal, .dropdown {
background: #1e1e1e;
}
/* DO: Progressive elevation */
.card { background: var(--surface-1); }
.dropdown { background: var(--surface-3); }
.modal { background: var(--surface-5); }Mistake 3: Too Much Contrast in Elevation
/* DON'T: Jarring jumps */
--surface-0: #000000;
--surface-1: #444444; /* Too bright! */
/* DO: Subtle progression */
--surface-0: #121212;
--surface-1: #1e1e1e; /* +5% white */Testing Elevation
1. Squint test: Can you still see hierarchy when squinting? 2. Screenshot grayscale: Does hierarchy survive desaturation? 3. Low brightness: Is elevation visible at minimum brightness? 4. High brightness: Does it wash out in bright environments?
OLED Optimization Guide
How OLED Displays Work
OLED (Organic Light-Emitting Diode) pixels emit their own light. Unlike LCD:
- Black pixels are completely off (true black)
- Each pixel consumes power proportional to its brightness
- Dark content = less power consumption
Battery Savings Research
Google's 2018 Study (Android)
| Brightness | Light Theme | Dark Theme | Savings |
|---|---|---|---|
| 100% | 100% baseline | 63% | 37% |
| 50% | 100% baseline | 79% | 21% |
| 20% | 100% baseline | 91% | 9% |
Purdue University 2021 Study
| App | Max Brightness Savings | Average Use Savings |
|---|---|---|
| Google Maps | 31% | 9% |
| Google News | 43% | 14% |
| YouTube | 47% | 7% |
| Calculator | 52% | 17% |
| Phone | 30% | 6% |
| Calendar | 48% | 17% |
Key Finding: Dark mode saves 39-47% battery at maximum brightness, but only 3-9% at typical (30-40%) brightness.
Design Implications
Pure Black (#000000) Considerations
Pros:
- Maximum battery savings on OLED
- True off state for pixels
Cons:
- "Black smearing" during scrolling (pixels slow to turn on)
- Harsh contrast with content
- OLED burn-in risk at edges
Recommendation: Use near-black (#0c1222, #121212) instead of pure black.
Color Power Consumption
Colors consume different amounts of power on OLED:
| Color | Relative Power | Notes |
|---|---|---|
| Black | 0% | Pixels off |
| Blue | ~25% | Most efficient hue |
| Red | ~40% | Mid efficiency |
| Green | ~60% | Less efficient |
| White | 100% | All subpixels on |
| Yellow | ~80% | Red + Green |
| Cyan | ~65% | Green + Blue |
| Magenta | ~55% | Red + Blue |
Implication: Blue-tinted dark themes are more battery-efficient than warm/green-tinted ones.
Optimized Dark Palette for OLED
:root.theme-dark.oled-optimized {
/* Near-black base (avoid pure #000 for smearing) */
--bg-primary: #050505;
--bg-secondary: #0a0a0a;
--bg-elevated: #121212;
/* Blue-tinted accents (more efficient) */
--accent-primary: #60a5fa; /* Blue-400 */
--accent-secondary: #818cf8; /* Indigo-400 */
/* Avoid bright warm colors for large areas */
--warning: #fbbf24; /* Use sparingly */
--error: #f87171; /* Use sparingly */
/* Text: off-white reduces power vs pure white */
--text-primary: #e5e5e5;
--text-secondary: #a3a3a3;
}Scroll Performance: Black Smearing
OLED pixels have asymmetric response times:
- On → Off: Fast (~1ms)
- Off → On: Slow (~10-20ms)
This causes "black smearing" when scrolling dark content.
Mitigation Strategies
1. Avoid pure black next to moving content
/* Instead of pure black backgrounds */
--bg: #000000; /* Smearing risk */
/* Use very dark gray */
--bg: #0a0a0a; /* Minimal smearing */2. Add subtle background patterns
.scrollable-area {
/* Subtle noise keeps pixels slightly active */
background:
#0a0a0a
url("data:image/svg+xml,...") /* 1% noise pattern */;
}3. Use blur/gradient edges
.scroll-indicator {
/* Gradient prevents hard black edge */
background: linear-gradient(
transparent,
rgba(0, 0, 0, 0.5),
#0a0a0a
);
}PWA/App Considerations
Theme Color Meta Tag
<!-- Light mode -->
<meta name="theme-color" content="#ffffff"
media="(prefers-color-scheme: light)">
<!-- Dark mode - use your dark bg color -->
<meta name="theme-color" content="#0c1222"
media="(prefers-color-scheme: dark)">Status Bar Styling (Mobile)
/* iOS Safari */
:root.theme-dark {
/* Ensure status bar blends with app */
--apple-system-background: #0c1222;
}
/* Android Chrome */
@media (display-mode: standalone) {
:root.theme-dark body {
/* Match system UI */
background: #0c1222;
}
}Testing OLED Optimization
1. Power profiler: Use Android Studio's Energy Profiler 2. Visual inspection: Check for smearing during scroll 3. Burn-in test: Static elements should use varied colors 4. Brightness range: Test at 20%, 50%, 100% brightness
OLED vs LCD Trade-offs
| Factor | OLED Priority | LCD Priority |
|---|---|---|
| Battery | Pure/near black | Doesn't matter |
| Contrast | Can use high contrast | Avoid pure black |
| Smearing | Use #0a0a0a+ | Pure black OK |
| Burn-in | Vary static elements | Not a concern |
| Response | Consider pixel on/off | Even response |
Recommendation
For web apps serving both OLED and LCD:
:root.theme-dark {
/* Balanced: works for both display types */
--bg-primary: #0c1222; /* Near-black, no smearing */
--bg-secondary: #151b2e; /* Subtle elevation */
--text-primary: #f1f5f9; /* Off-white, reduces power */
}
/* Optional: OLED-specific overrides for native apps */
@media (dynamic-range: high) {
:root.theme-dark.oled-mode {
--bg-primary: #050505; /* Closer to true black */
}
}