Agent skill
dark-mode
Implements theme switching with semantic tokens and prefers-color-scheme detection. Use when adding dark mode, light/dark themes, color scheme toggles, or converting hardcoded colors to theme-aware tokens.
Install this agent skill to your Project
npx add-skill https://github.com/dylantarre/design-system-skills/tree/main/skills/patterns/dark-mode
SKILL.md
Dark Mode Implementation
Overview
Implement robust dark mode using semantic color tokens, CSS custom properties, and system preference detection. Creates a theme system that's maintainable, accessible, and works across frameworks.
When to Use
- Adding dark mode to an existing project
- Setting up a theme system from scratch
- Converting hardcoded colors to semantic tokens
- Implementing system preference detection
- Building a theme toggle component
Quick Reference: Theme Architecture
| Layer | Purpose | Example |
|---|---|---|
| Primitive tokens | Raw color values | --color-blue-500: #3b82f6 |
| Semantic tokens | Purpose-based aliases | --color-primary: var(--color-blue-500) |
| Theme tokens | Mode-specific values | --color-bg: var(--color-gray-50) in light |
| Component tokens | Component-specific | --button-bg: var(--color-primary) |
The Process
- Audit existing colors: Find all color usage in codebase
- Define primitive palette: Use color-scale skill if needed
- Create semantic tokens: Map primitives to purposes
- Define theme tokens: Light and dark values
- Choose switching mechanism: CSS classes, data attributes, or media query
- Add system detection:
prefers-color-scheme - Implement persistence: localStorage or cookies
- Handle edge cases: Images, shadows, third-party components
Implementation Checklist
Copy this checklist and track progress:
Dark Mode Implementation:
- [ ] Audit colors in codebase (grep for hex, rgb, hsl, color names)
- [ ] Create semantic token layer (bg, text, border, surface, interactive, status)
- [ ] Define light and dark theme values for all semantic tokens
- [ ] Implement switching mechanism (data-theme attribute recommended)
- [ ] Add system preference detection (prefers-color-scheme)
- [ ] Add inline script in <head> to prevent flash of wrong theme
- [ ] Build toggle component with localStorage persistence
- [ ] Handle edge cases (images, shadows, third-party components, form inputs)
- [ ] Test contrast ratios meet WCAG AA in both themes
Theme Token Architecture
Layer 1: Primitive Tokens (Theme-Independent)
:root {
/* Gray scale */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* Brand colors */
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-blue-400: #60a5fa;
/* Semantic colors */
--color-green-500: #22c55e;
--color-red-500: #ef4444;
--color-amber-500: #f59e0b;
}
Layer 2: Semantic Theme Tokens
/* Light theme (default) */
:root,
[data-theme="light"] {
/* Backgrounds */
--color-bg-primary: var(--color-gray-50);
--color-bg-secondary: var(--color-gray-100);
--color-bg-tertiary: var(--color-gray-200);
--color-bg-inverse: var(--color-gray-900);
/* Surfaces (cards, modals, dropdowns) */
--color-surface-primary: #ffffff;
--color-surface-secondary: var(--color-gray-50);
--color-surface-elevated: #ffffff;
/* Text */
--color-text-primary: var(--color-gray-900);
--color-text-secondary: var(--color-gray-600);
--color-text-tertiary: var(--color-gray-500);
--color-text-inverse: var(--color-gray-50);
--color-text-disabled: var(--color-gray-400);
/* Borders */
--color-border-primary: var(--color-gray-200);
--color-border-secondary: var(--color-gray-300);
--color-border-focus: var(--color-blue-500);
/* Interactive */
--color-interactive-primary: var(--color-blue-500);
--color-interactive-primary-hover: var(--color-blue-600);
--color-interactive-secondary: var(--color-gray-100);
--color-interactive-secondary-hover: var(--color-gray-200);
/* Status */
--color-status-success: var(--color-green-500);
--color-status-warning: var(--color-amber-500);
--color-status-error: var(--color-red-500);
--color-status-info: var(--color-blue-500);
/* Shadows - more visible in light mode */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Overlay */
--color-overlay: rgb(0 0 0 / 0.5);
}
/* Dark theme */
[data-theme="dark"] {
/* Backgrounds */
--color-bg-primary: var(--color-gray-950);
--color-bg-secondary: var(--color-gray-900);
--color-bg-tertiary: var(--color-gray-800);
--color-bg-inverse: var(--color-gray-50);
/* Surfaces - slightly elevated from background */
--color-surface-primary: var(--color-gray-900);
--color-surface-secondary: var(--color-gray-800);
--color-surface-elevated: var(--color-gray-800);
/* Text */
--color-text-primary: var(--color-gray-50);
--color-text-secondary: var(--color-gray-400);
--color-text-tertiary: var(--color-gray-500);
--color-text-inverse: var(--color-gray-900);
--color-text-disabled: var(--color-gray-600);
/* Borders - less contrast in dark mode */
--color-border-primary: var(--color-gray-800);
--color-border-secondary: var(--color-gray-700);
--color-border-focus: var(--color-blue-400);
/* Interactive - lighter shades for dark bg */
--color-interactive-primary: var(--color-blue-500);
--color-interactive-primary-hover: var(--color-blue-400);
--color-interactive-secondary: var(--color-gray-800);
--color-interactive-secondary-hover: var(--color-gray-700);
/* Status - same or slightly adjusted */
--color-status-success: var(--color-green-500);
--color-status-warning: var(--color-amber-500);
--color-status-error: var(--color-red-500);
--color-status-info: var(--color-blue-400);
/* Shadows - less visible, add subtle glow */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
/* Overlay */
--color-overlay: rgb(0 0 0 / 0.7);
}
Switching Mechanisms
Option 1: Data Attribute (Recommended)
<html data-theme="light">
[data-theme="light"] { /* light tokens */ }
[data-theme="dark"] { /* dark tokens */ }
Pros: Explicit, debuggable, works with SSR Cons: Requires JavaScript to toggle
Option 2: CSS Class
<html class="dark">
:root { /* light tokens */ }
.dark { /* dark tokens */ }
Pros: Simple, Tailwind-compatible Cons: Can conflict with other classes
Option 3: Media Query Only (No Toggle)
:root { /* light tokens */ }
@media (prefers-color-scheme: dark) {
:root { /* dark tokens */ }
}
Pros: Zero JavaScript, respects system Cons: No user override
Option 4: Hybrid (Best UX)
/* Default: follow system */
:root { /* light tokens */ }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* dark tokens */ }
}
/* Manual overrides */
[data-theme="light"] { /* light tokens */ }
[data-theme="dark"] { /* dark tokens */ }
JavaScript Implementation
Vanilla JavaScript
// theme.js
const STORAGE_KEY = 'theme';
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getStoredTheme() {
return localStorage.getItem(STORAGE_KEY);
}
function setTheme(theme) {
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
document.documentElement.dataset.theme = resolvedTheme;
localStorage.setItem(STORAGE_KEY, theme);
}
function initTheme() {
const stored = getStoredTheme();
const theme = stored || 'system';
setTheme(theme);
// Listen for system changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (getStoredTheme() === 'system' || !getStoredTheme()) {
document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
}
});
}
// Initialize on load
initTheme();
// Export for toggle button
window.toggleTheme = () => {
const current = document.documentElement.dataset.theme;
setTheme(current === 'dark' ? 'light' : 'dark');
};
Prevent Flash of Wrong Theme (FOWT)
Add inline script in <head> before CSS:
<head>
<script>
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
})();
</script>
<link rel="stylesheet" href="styles.css">
</head>
Framework Integration
React
// ThemeProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) setThemeState(stored);
}, []);
useEffect(() => {
const root = document.documentElement;
const systemDark = window.matchMedia('(prefers-color-scheme: dark)');
const updateTheme = () => {
const resolved = theme === 'system'
? (systemDark.matches ? 'dark' : 'light')
: theme;
setResolvedTheme(resolved);
root.dataset.theme = resolved;
};
updateTheme();
systemDark.addEventListener('change', updateTheme);
return () => systemDark.removeEventListener('change', updateTheme);
}, [theme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
};
// ThemeToggle.tsx
import { useTheme } from './ThemeProvider';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
Tailwind CSS
tailwind.config.js:
module.exports = {
darkMode: ['selector', '[data-theme="dark"]'],
// or: darkMode: 'class', // uses .dark class
theme: {
extend: {
colors: {
// Map to CSS variables
bg: {
primary: 'var(--color-bg-primary)',
secondary: 'var(--color-bg-secondary)',
},
text: {
primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)',
},
},
},
},
};
Usage:
<!-- Using CSS variable colors -->
<div class="bg-bg-primary text-text-primary">
Content
</div>
<!-- Or using Tailwind's dark: variant -->
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-50">
Content
</div>
Edge Cases
Images
/* Dim images in dark mode */
[data-theme="dark"] img:not([data-theme-safe]) {
filter: brightness(0.9);
}
/* Invert diagrams/illustrations */
[data-theme="dark"] img[data-invert] {
filter: invert(1) hue-rotate(180deg);
}
/* Provide alternate images */
<picture>
<source srcset="dark-logo.svg" media="(prefers-color-scheme: dark)">
<img src="light-logo.svg" alt="Logo">
</picture>
SVG Icons
/* Icons that use currentColor work automatically */
.icon {
color: var(--color-text-primary);
}
/* Filled icons may need explicit colors */
[data-theme="dark"] .icon-filled {
fill: var(--color-text-primary);
}
Third-Party Components
/* Override third-party styles */
[data-theme="dark"] .third-party-component {
--third-party-bg: var(--color-surface-primary);
--third-party-text: var(--color-text-primary);
}
Code Blocks / Syntax Highlighting
/* Light theme syntax */
:root {
--code-bg: var(--color-gray-100);
--code-text: var(--color-gray-800);
--code-keyword: #d73a49;
--code-string: #22863a;
}
/* Dark theme syntax */
[data-theme="dark"] {
--code-bg: var(--color-gray-900);
--code-text: var(--color-gray-100);
--code-keyword: #ff7b72;
--code-string: #a5d6ff;
}
Form Inputs
/* Ensure form elements follow theme */
input, textarea, select {
background-color: var(--color-surface-primary);
color: var(--color-text-primary);
border-color: var(--color-border-primary);
}
/* Autofill override */
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--color-surface-primary) inset;
-webkit-text-fill-color: var(--color-text-primary);
}
Accessibility Considerations
- Sufficient contrast: Test both themes with color-contrast skill
- Focus visibility: Ensure focus rings visible in both themes
- Motion preferences: Consider reduced-motion for transitions
- Persist preference: Remember user's choice
- System preference: Respect
prefers-color-schemeby default
/* Smooth theme transition (respect motion preferences) */
@media (prefers-reduced-motion: no-preference) {
:root {
transition: background-color 200ms ease, color 200ms ease;
}
}
Testing Checklist
- Light mode renders correctly
- Dark mode renders correctly
- Toggle switches between modes
- System preference detected on first visit
- User preference persists across sessions
- System preference change updates theme (if set to "system")
- No flash of wrong theme on page load
- All text has sufficient contrast
- Focus indicators visible in both modes
- Images/icons display appropriately
- Third-party components styled correctly
- Forms and inputs themed properly
Common Mistakes
| Mistake | Fix |
|---|---|
| Hardcoding colors | Use CSS variables everywhere |
| Only theming backgrounds | Theme text, borders, shadows too |
| Forgetting scrollbars | Add color-scheme: light dark |
| Ignoring system preference | Use prefers-color-scheme as default |
| Flash on load | Inline script in <head> |
| Not testing contrast | Run accessibility audit on both themes |
| Over-transitioning | Only transition color properties |
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
getting-started
Introduction to the design system skills plugin. Use when first installing the plugin, exploring available skills, or planning a design system implementation.
responsive-typography
Implements fluid typography using CSS clamp() that scales with viewport. Use when creating responsive font sizes, viewport-aware headings, or type that adapts smoothly without breakpoints.
spacing-scale
Generates consistent spacing tokens using base values and ratios. Use when creating margin/padding systems, gap tokens, or layout spacing. Outputs CSS custom properties, Tailwind config, or JSON tokens.
color-scale
Generates perceptually uniform OKLCH color palettes from brand colors. Use when creating color systems, theme palettes, or converting hex values to 11-step scales. Outputs CSS custom properties, Tailwind config, or JSON tokens.
shadow-scale
Generates layered box-shadow elevation tokens for depth hierarchy. Use when creating elevation systems, card shadows, modal depth, or dark mode shadow variants. Outputs CSS, Tailwind, or JSON.
breakpoints
Generates responsive breakpoint tokens for media queries and container queries. Use when setting up responsive design, mobile-first layouts, or viewport-based styling. Outputs CSS, Tailwind, or JSON.
Didn't find tool you were looking for?