{
  "id": "add-dark-mode",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "zh",
  "url": "/zh/playbooks/add-dark-mode",
  "title": "Prompt-to-PR: 添加深色模式",
  "description": "端到端标准操作流程，使用 next-themes 在 Next.js Tailwind 应用中实现无闪烁深色模式——采用 class 策略、系统偏好和持久化切换。",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "TypeScript",
    "Tailwind"
  ],
  "tags": [
    "nextjs",
    "tailwind",
    "typescript"
  ],
  "difficulty": "easy",
  "updated": "2026-06-08",
  "markdown": "深色模式实现不当会导致每次页面加载时出现错误的主题闪烁（FOIT）。本操作手册将使用 `next-themes` 配合 Tailwind 的 `class` 策略，产生无闪烁实现。\n\n## 1. 需求\n\n为使用 Tailwind 的 Next.js 15 App Router 站点添加持久化深色/浅色/系统模式切换。主题必须：\n- 初始加载无闪烁（深色模式下无白色闪屏）。\n- 持久化到 `localStorage`。\n- 默认跟随操作系统偏好（`system`）。\n- 通过站点标题中的按钮进行切换。\n\n## 2. 第一个提示\n\n```txt title=\"First Prompt\"\nAdd flicker-free dark mode to this Next.js 15 App Router project with Tailwind.\n\nRequirements:\n1. Install `next-themes`.\n2. In `tailwind.config.ts`, set `darkMode: \"class\"`.\n3. Create `src/components/ThemeProvider.tsx` — a thin Client Component wrapper\n   around `next-themes` ThemeProvider:\n     <ThemeProvider attribute=\"class\" defaultTheme=\"system\" enableSystem>\n       {children}\n     </ThemeProvider>\n   Mark it \"use client\".\n4. Wrap the root layout's `<body>` with ThemeProvider in\n   `src/app/layout.tsx`. Keep the body tag; just wrap children.\n5. Create `src/components/ThemeToggle.tsx` — a Client Component with a button\n   that cycles: light -> dark -> system. Use `useTheme()` from next-themes.\n   Show the current theme as an icon or label. Handle the mounted check to\n   avoid hydration mismatch (render null or a skeleton until mounted).\n6. Add `<ThemeToggle />` to the existing site header/nav component.\n7. Do not change any color values or CSS — only add `dark:` Tailwind variants\n   where the existing design clearly has background and text colors that need\n   inversion. Update: bg-white -> bg-white dark:bg-gray-950, text-gray-900\n   -> text-gray-900 dark:text-gray-100.\n   Do not invert every element — only top-level layout containers.\n```\n\n## 3. 预期文件变更\n\n```txt\npackage.json                          (next-themes)\ntailwind.config.ts                    (darkMode: \"class\")\nsrc/app/layout.tsx                    (ThemeProvider wrapper)\nsrc/components/ThemeProvider.tsx      (new — \"use client\" wrapper)\nsrc/components/ThemeToggle.tsx        (new — theme cycling button)\nsrc/components/Header.tsx             (add ThemeToggle — or whichever nav file)\n```\n\n## 4. 审查清单\n\n- `tailwind.config.ts` 包含 `darkMode: \"class\"` — 而不是 `\"media\"`（media 不允许切换）。\n- `ThemeProvider` 是客户端组件（`\"use client\"`），包裹来自服务端组件根布局的 `children`。\n- 根 `<html>` 或 `<body>` 没有硬编码的 `class=\"dark\"` — `next-themes` 会动态添加此属性。\n- `ThemeToggle` 在 `mounted === true` 之前渲染 `null`（或占位符），以防止水合不匹配。\n- `<html>` 上没有缺失 `suppressHydrationWarning` — `next-themes` 要求此属性；确认它出现在 `<html>` 标签上。\n- `dark:` 变体仅出现在布局级容器上，而不是每个单独的元素。\n- `next-themes` 使用的 `localStorage` 键是默认的 `theme` — 没有与其他存储冲突的自定义键。\n\n## 5. 测试命令\n\n```bash\nbun dev\n\n# In Chrome DevTools: set prefers-color-scheme to dark\n# Reload — confirm no white flash before dark theme applies\n\n# Toggle the button — confirm light/dark/system cycle\n# Reload after each — confirm preference persists\n\nbun tsc --noEmit\nbun run build\n# Confirm no hydration warnings in the browser console after bun run start\n```\n\n## 6. 常见失败\n\n- **加载时白色闪烁（FOIT）** — `ThemeProvider` 未包裹根布局，或者 `<html>` 缺少 `suppressHydrationWarning`。`next-themes` 会在 `<head>` 中注入脚本以在绘制前设置 class — 这仅在包裹最外层元素时有效。\n- **水合不匹配错误** — `ThemeToggle` 在 `mounted` 之前渲染了主题特定内容（图标）。添加 `const [mounted, setMounted] = useState(false)` 和 `useEffect(() => setMounted(true), [])`。在挂载前返回占位符。\n- **`dark:` 类未应用** — `tailwind.config.ts` 仍然是 `darkMode: \"media\"` 或缺少该键。class 策略需要 `\"class\"` 值。\n- **切换不可见** — `<ThemeToggle>` 在没有 `\"use client\"` 边界的服务端组件中导入。导入链必须经过客户端组件。\n\n## 7. 修复提示\n\n```txt title=\"Fix Prompt\"\nThere is a white flash before dark mode applies on page load.\n\nConfirm:\n1. In src/app/layout.tsx, the <html> tag has suppressHydrationWarning.\n2. ThemeProvider wraps {children} directly inside <body>.\n3. ThemeProvider uses attribute=\"class\" (not attribute=\"data-theme\").\n\nnext-themes injects an inline script into <head> that reads localStorage and\nsets the class on <html> synchronously before paint. This only works if the\nThemeProvider is rendered above the content it styles.\n```\n\n## 8. PR 描述\n\n```md title=\"PR description\"\n## Feature: Flicker-free dark mode via next-themes\n\n- `tailwind.config.ts`: `darkMode: \"class\"`\n- `ThemeProvider` wraps root layout — `next-themes` sets `<html class=\"dark\">`\n  synchronously before first paint (no flash)\n- `ThemeToggle` cycles light / dark / system; persists to `localStorage`\n- Mounted guard in ThemeToggle prevents hydration mismatch\n- `dark:` variants added to top-level layout containers only\n```"
}