Claude-skill-registry 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
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/dark-mode" ~/.claude/skills/majiayu000-claude-skill-registry-dark-mode && rm -rf "$T"
manifest:
skills/data/dark-mode/SKILL.mdsource content
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 | |
| Semantic tokens | Purpose-based aliases | |
| Theme tokens | Mode-specific values | in light |
| Component tokens | Component-specific | |
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
by defaultprefers-color-scheme
/* 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 |
| Ignoring system preference | Use as default |
| Flash on load | Inline script in |
| Not testing contrast | Run accessibility audit on both themes |
| Over-transitioning | Only transition color properties |