P PasteCode
行动手册

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

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

CursorClaude CodeCodexWindsurf Next.jsTypeScriptTailwind
.md .json 难度: 简单 更新于 2026年6月8日

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

1. 需求

为使用 Tailwind 的 Next.js 15 App Router 站点添加持久化深色/浅色/系统模式切换。主题必须:

  • 初始加载无闪烁(深色模式下无白色闪屏)。
  • 持久化到 localStorage
  • 默认跟随操作系统偏好(system)。
  • 通过站点标题中的按钮进行切换。

2. 第一个提示

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. 预期文件变更

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 会动态添加此属性。
  • ThemeTogglemounted === true 之前渲染 null(或占位符),以防止水合不匹配。
  • <html> 上没有缺失 suppressHydrationWarningnext-themes 要求此属性;确认它出现在 <html> 标签上。
  • dark: 变体仅出现在布局级容器上,而不是每个单独的元素。
  • next-themes 使用的 localStorage 键是默认的 theme — 没有与其他存储冲突的自定义键。

5. 测试命令

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

7. 修复提示

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 描述

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