关于 dark-mode-implementer
dark-mode-implementer 是指导开发者为 Web 应用添加明暗主题的技能,涵盖系统偏好检测、选择持久化、主题切换与避免初始显示错误主题。
主要功能
- 01CSS、Tailwind 与 React 实现方案
- 02语义化颜色令牌
- 03检测系统主题偏好
- 04持久保存主题选择
- 05避免初始主题闪烁
- 06主题切换组件模式
适用场景
- 01为静态网站添加暗色模式
- 02为 React 应用构建主题提供器
- 03保存用户的主题选择
- 04使用 Playwright 测试两种主题
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ patricio0312rev/ skills --skill dark-mode-implementer安装后在 Claude Code 中输入/dark-mode-implementer确认已加载。
详细介绍
深色模式实现器
构建健壮的深色/浅色模式主题,支持检测系统偏好并持久化存储。
核心工作流
- 选择策略:仅使用 CSS、Tailwind 或 React context
- 定义颜色令牌:创建语义化颜色变量
- 实现切换功能:添加主题切换组件
- 检测系统偏好:遵循
prefers-color-scheme - 持久化选择:将偏好存储在 localStorage 中
- 防止闪烁:正确处理初始加载
策略对比
| 策略 | 最适合 | 复杂度 |
|---|---|---|
Tailwind class | React/Vue/Svelte 应用 | 低 |
CSS media | 简单静态网站 | 极低 |
| CSS Variables + JS | 与框架无关 | 中 |
| React Context | 复杂的 React 应用 | 中 |
Tailwind CSS 深色模式
启用 Class 策略
// tailwind.config.js
module.exports = {
darkMode: 'class', // or 'media' for system-only
theme: {
extend: {
colors: {
// Semantic color tokens
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: 'hsl(var(--primary))',
muted: 'hsl(var(--muted))',
},
},
},
};
CSS 变量设置
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--primary: 221 83% 53%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 221 83% 53%;
--radius: 0.5rem;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--primary: 217 91% 60%;
--primary-foreground: 222 47% 11%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62% 30%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 224 76% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
使用深色模式类
<div class="bg-white dark:bg-gray-900">
<h1 class="text-gray-900 dark:text-white">Title</h1>
<p class="text-gray-600 dark:text-gray-300">Description</p>
<button class="bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700">
Action
</button>
</div>
React 主题提供程序
完整主题上下文
// lib/theme-context.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const STORAGE_KEY = 'theme';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const [mounted, setMounted] = useState(false);
// Get system preference
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
};
// Apply theme to document
const applyTheme = (theme: Theme) => {
const root = document.documentElement;
const resolved = theme === 'system' ? getSystemTheme() : theme;
root.classList.remove('light', 'dark');
root.classList.add(resolved);
setResolvedTheme(resolved);
};
// Set theme and persist
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
applyTheme(newTheme);
};
// Initialize theme on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
const initialTheme = stored || 'system';
setThemeState(initialTheme);
applyTheme(initialTheme);
setMounted(true);
}, []);
// Listen for system preference changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
applyTheme('system');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
// Prevent hydration mismatch
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
防止错误主题闪烁
// app/layout.tsx
import { ThemeProvider } from '@/lib/theme-context';
// Inline script to prevent flash
const themeScript = `
(function() {
const stored = localStorage.getItem('theme');
const theme = stored || 'system';
const resolved = theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: theme;
document.documentElement.classList.add(resolved);
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
主题切换组件
简易切换按钮
// components/ThemeToggle.tsx
'use client';
import { useTheme } from '@/lib/theme-context';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
>
{resolvedTheme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</button>
);
}
三向切换(浅色/深色/系统)
// components/ThemeSelector.tsx
'use client';
import { useTheme } from '@/lib/theme-context';
import { Monitor, Moon, Sun } from 'lucide-react';
const themes = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
] as const;
export function ThemeSelector() {
const { theme, setTheme } = useTheme();
return (
<div className="flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
{themes.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`
flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium
transition-colors
${theme === value
? 'bg-white text-gray-900 shadow dark:bg-gray-700 dark:text-white'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
}
`}
aria-label={`Switch to ${label} theme`}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
);
}
动画切换开关
// components/ThemeSwitch.tsx
'use client';
import { useTheme } from '@/lib/theme-context';
import { motion } from 'framer-motion';
export function ThemeSwitch() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
return (
<button
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="relative h-8 w-14 rounded-full bg-gray-200 p-1 dark:bg-gray-700"
aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
>
<motion.div
className="flex h-6 w-6 items-center justify-center rounded-full bg-white shadow-md"
animate={{ x: isDark ? 24 : 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
>
<motion.span
initial={false}
animate={{ rotate: isDark ? 360 : 0 }}
transition={{ duration: 0.5 }}
>
{isDark ? '🌙' : '☀️'}
</motion.span>
</motion.div>
</button>
);
}
仅使用 CSS 的深色模式
使用 prefers-color-scheme
/* For simple sites without JavaScript */
:root {
--bg: #ffffff;
--text: #1a1a1a;
--primary: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--text: #fafafa;
--primary: #60a5fa;
}
}
body {
background-color: var(--bg);
color: var(--text);
}
颜色令牌系统
语义化颜色命名
:root {
/* Background colors */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-tertiary: #f3f4f6;
--color-bg-inverse: #111827;
/* Text colors */
--color-text-primary: #111827;
--color-text-secondary: #4b5563;
--color-text-tertiary: #9ca3af;
--color-text-inverse: #ffffff;
/* Border colors */
--color-border-primary: #e5e7eb;
--color-border-secondary: #d1d5db;
/* Interactive colors */
--color-interactive-primary: #3b82f6;
--color-interactive-hover: #2563eb;
--color-interactive-active: #1d4ed8;
/* Status colors */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
}
.dark {
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-bg-tertiary: #334155;
--color-bg-inverse: #f8fafc;
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5e1;
--color-text-tertiary: #64748b;
--color-text-inverse: #0f172a;
--color-border-primary: #334155;
--color-border-secondary: #475569;
--color-interactive-primary: #60a5fa;
--color-interactive-hover: #3b82f6;
--color-interactive-active: #2563eb;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-info: #60a5fa;
}
使用 next-themes 的 Next.js
安装与设置
npm install next-themes
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// components/ThemeToggle.tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
图像与媒体
适配主题的图像
// Swap images based on theme
<picture>
<source srcSet="/dark-logo.svg" media="(prefers-color-scheme: dark)" />
<img src="/light-logo.svg" alt="Logo" />
</picture>
// With Tailwind
<img src="/light-logo.svg" alt="Logo" class="dark:hidden" />
<img src="/dark-logo.svg" alt="Logo" class="hidden dark:block" />
SVG 颜色适配
// SVG that adapts to theme
<svg className="text-gray-900 dark:text-white" fill="currentColor">
{/* ... */}
</svg>
测试深色模式
Playwright 测试
// tests/dark-mode.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Dark Mode', () => {
test('respects system preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
const html = page.locator('html');
await expect(html).toHaveClass(/dark/);
});
test('toggles theme correctly', async ({ page }) => {
await page.goto('/');
await page.click('[aria-label*="dark"]');
await expect(page.locator('html')).toHaveClass(/dark/);
await page.click('[aria-label*="light"]');
await expect(page.locator('html')).not.toHaveClass(/dark/);
});
test('persists theme preference', async ({ page }) => {
await page.goto('/');
await page.click('[aria-label*="dark"]');
await page.reload();
await expect(page.locator('html')).toHaveClass(/dark/);
});
});
最佳实践
- 防止闪烁:在 React hydration 前使用内联脚本
- 遵循系统偏好:默认使用“系统”主题
- 持久化选择:存储在 localStorage 中
- 使用语义化令牌:不要硬编码颜色
- 测试两种主题:确保对比度和可读性
- 处理图像:提供深色模式版本
- 考虑过渡效果:添加平滑的颜色过渡
- 无障碍性:保持符合 WCAG 的对比度
输出检查清单
每个深色模式实现都应包含:
- 使用
darkMode: 'class'配置 Tailwind - 用于浅色和深色主题的 CSS 变量
- 使用 context 的主题提供程序
- 检测系统偏好
- localStorage 持久化
- 防闪烁脚本
- 主题切换组件
- 切换控件上的无障碍标签
- 覆盖所有 UI 元素的颜色令牌
- 图像/SVG 的深色模式版本
- 平滑的过渡动画
- Playwright/Jest 测试
评论
支持 Markdown