Next.js 暗黑模式实现:next-themes 完全指南
说实话,我第一次在 Next.js 项目里做暗黑模式的时候,真的被坑惨了。页面加载的那一瞬间,先是白光一闪,然后才切换到暗黑模式——那个闪烁的效果简直让人抓狂。用户在评论里吐槽说”眼睛都要被闪瞎了”,我才意识到这个问题有多严重。
后来试了好几个方案,自己手写、用 use-dark-mode 库,甚至翻了无数篇教程,最后发现 next-themes 才是真正的救星。现在我的项目用的全是它,零闪烁,配置超简单,系统主题也能完美跟随。这篇文章就把我踩过的坑和找到的解决方案都分享给你。
为什么我最终选择了 next-themes
刚开始我也纠结过要不要自己写一套主题切换逻辑。毕竟就是读个 localStorage,改个 class 嘛,看起来很简单。但实际动手才发现,Next.js 的服务端渲染特性让这件事变得异常复杂。
我试过几种方案:
手写方案:最大的问题就是闪烁。因为 SSR 的时候服务器不知道用户的主题偏好,渲染出来的是默认的亮色主题,等到客户端 hydration 的时候才能读取 localStorage,这时候切换到暗色主题就会有明显的闪烁。
use-dark-mode:这个库其实不错,但它不是专门为 Next.js 设计的,在 SSR 场景下还是会有一些兼容问题。
theme-ui:功能很强大,但对于只需要暗黑模式切换的场景来说太重了,bundle size 也大。
最后我发现了 next-themes,GitHub 上 6000+ Star,专门为 Next.js 设计,零依赖,体积只有不到 1kb gzipped。关键是它真的做到了零闪烁,开箱即用的系统主题支持,还有自动持久化。TypeScript 支持也很完善,用起来非常舒服。
完整实现步骤
安装依赖
老规矩,先装包:
npm install next-themes
或者你用 pnpm、yarn 都行:
pnpm add next-themes
# 或
yarn add next-themes
创建 ThemeProvider 组件
接下来要创建一个 Provider 组件。我一般会在项目里建一个 providers 或者 components 目录来放这类东西。
创建文件 providers/theme-provider.tsx:
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
注意这里必须标记为 'use client',因为 next-themes 需要访问浏览器的 API。这是我当初踩的第一个坑——一开始没加这个标记,报了一堆 hydration 错误。
在 Layout 中集成
现在把 ThemeProvider 加到你的根布局里。如果你用的是 App Router(Next.js 13+),应该是 app/layout.tsx:
import { ThemeProvider } from '@/providers/theme-provider'
import './globals.css'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
这里有几个关键配置,我一个个解释:
attribute="class":告诉 next-themes 通过修改 <html> 元素的 class 来切换主题。配合 Tailwind CSS 的 dark: 前缀使用非常方便。
defaultTheme="system":默认跟随系统主题。用户第一次访问的时候会自动检测操作系统的主题偏好。
enableSystem:启用系统主题检测功能。这个必须开启,否则 defaultTheme="system" 不会生效。
disableTransitionOnChange:禁用切换时的过渡动画。这个可以根据你的需求调整,但我建议开启,因为暗黑模式切换时如果有过渡动画,会看到所有元素一起动画,视觉效果反而不太好。
suppressHydrationWarning:这个是加在 <html> 标签上的,非常重要!因为 next-themes 会在客户端 hydration 之前修改 html 元素的 class,如果不加这个属性,React 会报 warning。
创建主题切换按钮
有了 Provider,现在就可以做一个切换按钮了。创建 components/theme-toggle.tsx:
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="切换主题"
>
{theme === 'dark' ? '🌞' : '🌙'}
</button>
)
}
这里有个小技巧:在组件加载完成之前返回 null。为什么要这样做?因为服务端渲染的时候拿不到主题信息,如果直接渲染会导致 hydration mismatch。等到客户端 mounted 之后,useTheme 才能正确返回当前主题。
如果你想做一个三态切换(light / dark / system),可以这样写:
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return null
const cycleTheme = () => {
if (theme === 'light') setTheme('dark')
else if (theme === 'dark') setTheme('system')
else setTheme('light')
}
const getIcon = () => {
if (theme === 'light') return '🌞'
if (theme === 'dark') return '🌙'
return '💻'
}
return (
<button
onClick={cycleTheme}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
{getIcon()}
</button>
)
}
闪烁问题深度解析
其实当初让我下决心研究这个问题的,就是那个烦人的闪烁。我花了不少时间才彻底搞明白它的原理。
FOUC 是怎么产生的
FOUC(Flash of Unstyled Content)在 Next.js 的暗黑模式实现中特别常见。问题的根源在于 SSR 和客户端状态不一致。
你想啊,服务端渲染的时候,Node.js 环境里没有 window 对象,也拿不到 localStorage,更不知道用户的系统主题偏好。所以服务端只能渲染一个默认主题(通常是亮色)。
然后 HTML 发送到浏览器,开始 hydration。这时候 React 要把服务端渲染的静态 HTML 变成可交互的组件。这个过程中,JavaScript 才能读取 localStorage,发现用户之前选的是暗色主题,于是修改 DOM,加上 dark class。
这个修改就会引起重新渲染,所有的样式都要从亮色切换到暗色——闪烁就是这么来的。
next-themes 的解决方案
next-themes 的解决方案很巧妙:它会在 <head> 里注入一个 blocking script。这个 script 会在页面渲染之前执行,立即读取 localStorage 里的主题设置,然后给 <html> 元素加上对应的 class。
大概是这样的逻辑:
(function() {
try {
const theme = localStorage.getItem('theme')
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const currentTheme = theme || systemTheme
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark')
}
} catch (e) {}
})()
因为这个 script 是同步执行的,会阻塞页面渲染,所以能保证在任何内容显示之前,正确的主题 class 就已经设置好了。这样一来,CSS 从一开始就应用了正确的样式,自然就不会有闪烁了。
常见错误配置
我见过很多人配置出问题,主要集中在这几个点:
忘记加 suppressHydrationWarning:
如果你忘了在 <html> 标签上加这个属性,控制台会一直报这样的警告:
Warning: Prop `className` did not match. Server: "" Client: "dark"
虽然不影响功能,但看着烦。
ThemeProvider 位置不对:
有人把 ThemeProvider 放在了 Server Component 里,或者放在 body 外面,都会有问题。记住,ThemeProvider 必须包裹你的页面内容,而且必须是 Client Component。
Tailwind 配置错误:
如果你的 tailwind.config.js 是这样的:
module.exports = {
darkMode: 'media',
}
那肯定有问题。media 模式是纯 CSS 方案,只能跟随系统主题,无法手动切换。应该改成:
module.exports = {
darkMode: 'class',
}
主题持久化与系统跟随
持久化机制
next-themes 默认会把你的主题选择保存到 localStorage 里,key 是 'theme'。这个行为是自动的,你不需要写任何额外代码。
如果你想自定义 storage key,可以这样配置:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="my-theme"
>
{children}
</ThemeProvider>
在某些场景下,你可能需要用 Cookie 而不是 localStorage。比如你想在服务端就知道用户的主题偏好,避免任何可能的闪烁。这时候可以这样做:
- 在中间件里读取 Cookie,设置到响应头
- 服务端渲染时根据响应头渲染对应主题
- 客户端同步 Cookie 和 localStorage
不过老实讲,对于大多数场景,next-themes 默认的方案已经够用了。
系统主题跟随
enableSystem 配置项让 next-themes 可以监听系统主题的变化。当用户在操作系统设置里切换深色/浅色模式时,如果你的应用当前主题是 system,就会自动跟随切换。
底层实现是监听 prefers-color-scheme 媒体查询:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
// 主题切换逻辑
})
用户也可以手动覆盖系统主题。比如系统是亮色模式,但他在你的网站上切换到暗色,next-themes 会记住这个选择,下次访问还是暗色。
多主题支持
虽然我们主要讨论的是暗黑模式,但 next-themes 其实支持任意多个主题。比如你可以做一个紫色主题、绿色主题之类的:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
themes={['light', 'dark', 'purple', 'green']}
>
{children}
</ThemeProvider>
然后在 CSS 里定义对应的样式:
.purple {
--background: #f3e8ff;
--foreground: #581c87;
}
.green {
--background: #dcfce7;
--foreground: #14532d;
}
配合 CSS 变量使用非常灵活。
实战技巧与常见问题
配合 Tailwind CSS
如果你用 Tailwind,配置就更简单了。首先确保 tailwind.config.js 里设置了:
module.exports = {
darkMode: 'class',
// 其他配置...
}
然后就可以愉快地使用 dark: 前缀了:
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold">标题</h1>
<p className="text-gray-600 dark:text-gray-400">段落文本</p>
</div>
Tailwind 的 dark: 变体会在 <html> 元素有 dark class 的时候生效,跟 next-themes 的工作方式完美契合。
动画与过渡
关于 disableTransitionOnChange 这个配置,我个人建议开启。因为如果你的 CSS 里有很多 transition 属性,切换主题的时候所有元素都会一起动画,看起来有点乱。
但如果你确实想要过渡效果,可以这样做:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
然后在全局 CSS 里加上:
* {
transition: background-color 0.2s ease, color 0.2s ease;
}
这样切换的时候会有淡入淡出的效果。不过我试过几次,总觉得没有过渡更干净利落。
TypeScript 类型支持
next-themes 的 TypeScript 支持很好。如果你想扩展主题类型,可以这样:
import { useTheme } from 'next-themes'
type Theme = 'light' | 'dark' | 'purple'
export function useCustomTheme() {
const { theme, setTheme } = useTheme()
return {
theme: theme as Theme,
setTheme: (theme: Theme) => setTheme(theme),
}
}
这样在使用的时候就有类型提示了,不会错误地设置一个不存在的主题。
常见问题排查
问题1:主题切换了但样式没变
检查这几点:
- Tailwind 的
darkMode配置是不是'class' - CSS 里是不是正确使用了
dark:前缀或者.dark选择器 - 浏览器控制台看看
<html>元素的 class 有没有正确添加
问题2:刷新页面还是会闪一下
如果还有闪烁,可能是:
- 忘了在
<html>上加suppressHydrationWarning - ThemeProvider 的位置不对
- 有其他脚本在干扰(比如 Google Analytics 之类的)
问题3:系统主题跟随不生效
检查:
enableSystem是不是设置为true- 浏览器是否支持
prefers-color-scheme(现代浏览器都支持) - 当前主题是不是
system(如果手动切换过,可能是light或dark)
总结
回想起来,从最开始被闪烁问题困扰,到现在能够顺畅地实现暗黑模式,next-themes 真的帮了大忙。它解决的不只是技术问题,更重要的是让用户体验变得更好。
核心要点再回顾一下:
- 使用
next-themes可以零配置解决 Next.js 暗黑模式的闪烁问题 - 记得在
<html>上加suppressHydrationWarning,ThemeProvider 要标记为客户端组件 - Tailwind 的
darkMode配置设为'class' - 主题切换按钮要等 mounted 后再渲染,避免 hydration 不匹配
- 系统主题跟随和手动切换可以完美共存
如果你还没在项目里用过 next-themes,真的建议试一试。官方文档也写得很清楚:github.com/pacocoursey/next-themes
现在就去给你的 Next.js 项目加上丝滑的暗黑模式吧!你的用户会感谢你的。
Next.js 暗黑模式实现完整流程
使用next-themes实现零闪烁的暗黑模式,支持系统主题跟随和手动切换
⏱️ 预计耗时: 30 分钟
- 1
步骤1: 安装 next-themes
安装依赖:
• npm install next-themes
或者使用其他包管理器:
• pnpm add next-themes
• yarn add next-themes
注意:next-themes是零依赖库,体积很小 - 2
步骤2: 配置 ThemeProvider
在根布局中添加:
• 创建providers.tsx文件(标记'use client')
• 使用ThemeProvider包裹children
• 在app/layout.tsx中导入使用
关键配置:
• attribute="class":使用class切换主题
• enableSystem:启用系统主题跟随
• storageKey:localStorage存储键名
注意:ThemeProvider必须是客户端组件 - 3
步骤3: 配置 Tailwind CSS
在tailwind.config.js中:
• 设置darkMode: 'class'
• 这样Tailwind会根据html标签的class切换主题
配置示例:
module.exports = {
darkMode: 'class',
// ... 其他配置
}
使用dark:前缀定义暗色样式:
className="bg-white dark:bg-gray-900" - 4
步骤4: 修复 hydration 警告
在html标签添加:
• suppressHydrationWarning属性
• 避免服务端和客户端主题不一致的警告
在layout.tsx中:
<html lang="zh" suppressHydrationWarning>
<body>{children}</body>
</html>
这样可以避免Next.js的hydration警告 - 5
步骤5: 创建主题切换按钮
使用useTheme hook:
• 创建ThemeToggle组件(标记'use client')
• 使用useTheme()获取theme和setTheme
• 等mounted后再渲染,避免hydration不匹配
示例:
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
切换主题
</button> - 6
步骤6: 测试和验证
测试要点:
• 测试手动切换主题(无闪烁)
• 测试系统主题跟随
• 测试刷新后主题保持
• 测试不同页面主题一致
检查清单:
• 页面加载无闪烁
• 主题切换流畅
• localStorage正确存储
• 系统主题切换自动跟随
常见问题
为什么页面加载时会闪烁?
next-themes 和其他主题库有什么区别?
如何实现系统主题跟随?
为什么需要 suppressHydrationWarning?
主题切换按钮为什么要等 mounted 后再渲染?
如何自定义主题切换逻辑?
next-themes 支持哪些主题?
12 分钟阅读 · 发布于: 2025年12月20日 · 修改于: 2026年1月22日
相关文章
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 单元测试实战:Jest + React Testing Library 完整配置指南

评论
使用 GitHub 账号登录后即可评论