# Prompt-to-PR: 添加深色模式

> 端到端标准操作流程，使用 next-themes 在 Next.js Tailwind 应用中实现无闪烁深色模式——采用 class 策略、系统偏好和持久化切换。

**Type:** Playbook  
**Tools:** Cursor, Claude Code, Codex, Windsurf  
**Stack:** Next.js, TypeScript, Tailwind  
**Difficulty:** easy  
**Updated:** 2026-06-08

---

深色模式实现不当会导致每次页面加载时出现错误的主题闪烁（FOIT）。本操作手册将使用 `next-themes` 配合 Tailwind 的 `class` 策略，产生无闪烁实现。

## 1. 需求

为使用 Tailwind 的 Next.js 15 App Router 站点添加持久化深色/浅色/系统模式切换。主题必须：
- 初始加载无闪烁（深色模式下无白色闪屏）。
- 持久化到 `localStorage`。
- 默认跟随操作系统偏好（`system`）。
- 通过站点标题中的按钮进行切换。

## 2. 第一个提示

```txt title="First Prompt"
Add flicker-free dark mode to this Next.js 15 App Router project with Tailwind.

Requirements:
1. Install `next-themes`.
2. In `tailwind.config.ts`, set `darkMode: "class"`.
3. Create `src/components/ThemeProvider.tsx` — a thin Client Component wrapper
   around `next-themes` ThemeProvider:
     <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
       {children}
     </ThemeProvider>
   Mark it "use client".
4. Wrap the root layout's `<body>` with ThemeProvider in
   `src/app/layout.tsx`. Keep the body tag; just wrap children.
5. Create `src/components/ThemeToggle.tsx` — a Client Component with a button
   that cycles: light -> dark -> system. Use `useTheme()` from next-themes.
   Show the current theme as an icon or label. Handle the mounted check to
   avoid hydration mismatch (render null or a skeleton until mounted).
6. Add `<ThemeToggle />` to the existing site header/nav component.
7. Do not change any color values or CSS — only add `dark:` Tailwind variants
   where the existing design clearly has background and text colors that need
   inversion. Update: bg-white -> bg-white dark:bg-gray-950, text-gray-900
   -> text-gray-900 dark:text-gray-100.
   Do not invert every element — only top-level layout containers.
```

## 3. 预期文件变更

```txt
package.json                          (next-themes)
tailwind.config.ts                    (darkMode: "class")
src/app/layout.tsx                    (ThemeProvider wrapper)
src/components/ThemeProvider.tsx      (new — "use client" wrapper)
src/components/ThemeToggle.tsx        (new — theme cycling button)
src/components/Header.tsx             (add ThemeToggle — or whichever nav file)
```

## 4. 审查清单

- `tailwind.config.ts` 包含 `darkMode: "class"` — 而不是 `"media"`（media 不允许切换）。
- `ThemeProvider` 是客户端组件（`"use client"`），包裹来自服务端组件根布局的 `children`。
- 根 `<html>` 或 `<body>` 没有硬编码的 `class="dark"` — `next-themes` 会动态添加此属性。
- `ThemeToggle` 在 `mounted === true` 之前渲染 `null`（或占位符），以防止水合不匹配。
- `<html>` 上没有缺失 `suppressHydrationWarning` — `next-themes` 要求此属性；确认它出现在 `<html>` 标签上。
- `dark:` 变体仅出现在布局级容器上，而不是每个单独的元素。
- `next-themes` 使用的 `localStorage` 键是默认的 `theme` — 没有与其他存储冲突的自定义键。

## 5. 测试命令

```bash
bun dev

# In Chrome DevTools: set prefers-color-scheme to dark
# Reload — confirm no white flash before dark theme applies

# Toggle the button — confirm light/dark/system cycle
# Reload after each — confirm preference persists

bun tsc --noEmit
bun run build
# Confirm no hydration warnings in the browser console after bun run start
```

## 6. 常见失败

- **加载时白色闪烁（FOIT）** — `ThemeProvider` 未包裹根布局，或者 `<html>` 缺少 `suppressHydrationWarning`。`next-themes` 会在 `<head>` 中注入脚本以在绘制前设置 class — 这仅在包裹最外层元素时有效。
- **水合不匹配错误** — `ThemeToggle` 在 `mounted` 之前渲染了主题特定内容（图标）。添加 `const [mounted, setMounted] = useState(false)` 和 `useEffect(() => setMounted(true), [])`。在挂载前返回占位符。
- **`dark:` 类未应用** — `tailwind.config.ts` 仍然是 `darkMode: "media"` 或缺少该键。class 策略需要 `"class"` 值。
- **切换不可见** — `<ThemeToggle>` 在没有 `"use client"` 边界的服务端组件中导入。导入链必须经过客户端组件。

## 7. 修复提示

```txt title="Fix Prompt"
There is a white flash before dark mode applies on page load.

Confirm:
1. In src/app/layout.tsx, the <html> tag has suppressHydrationWarning.
2. ThemeProvider wraps {children} directly inside <body>.
3. ThemeProvider uses attribute="class" (not attribute="data-theme").

next-themes injects an inline script into <head> that reads localStorage and
sets the class on <html> synchronously before paint. This only works if the
ThemeProvider is rendered above the content it styles.
```

## 8. PR 描述

```md title="PR description"
## Feature: Flicker-free dark mode via next-themes

- `tailwind.config.ts`: `darkMode: "class"`
- `ThemeProvider` wraps root layout — `next-themes` sets `<html class="dark">`
  synchronously before first paint (no flash)
- `ThemeToggle` cycles light / dark / system; persists to `localStorage`
- Mounted guard in ThemeToggle prevents hydration mismatch
- `dark:` variants added to top-level layout containers only
```