TailwindCSS 暗色模式如何实现?v3 和 v4 有什么区别?
TailwindCSS 做暗色模式,核心不是写两套 CSS,而是用 dark: 变体给同一个元素补一组暗色样式。真正容易踩坑的地方在于:你的项目用 Tailwind v3 还是 v4?暗色状态由系统偏好决定,还是由用户手动切换?这两个问题先想清楚,后面的代码会简单很多。
先决定暗色模式由谁触发
Tailwind 的暗色模式大致有两种思路:跟随系统,或由用户手动切换。如果只是文档页,跟随系统通常够用;如果是后台、SaaS、博客、控制台这类长期使用的产品,建议支持手动切换,并允许用户选择“跟随系统”。
Tailwind v3 怎么配置 darkMode?
在 Tailwind v3 里,常见配置写在 tailwind.config.js:
jsmodule.exports = { darkMode: 'media', content: ['./src/**/*.{html,js,ts,jsx,tsx,vue}'], theme: { extend: {} }, }
media 会根据浏览器的 prefers-color-scheme: dark 自动生效,不需要给 HTML 加类名。缺点是它不适合做“用户手动切换”。
如果要手动切换,v3.4.1 之后更推荐 selector:
jsmodule.exports = { darkMode: 'selector', }
这时只要根节点上有 dark 类,所有 dark: 样式都会生效:
html<html class="dark"> <body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100"> ... </body> </html>
老项目里还会看到 darkMode: 'class',它和 selector 的实际思路接近,都是靠选择器触发。新项目可以优先用 selector。
如果想用 data-theme="dark" 而不是 .dark,可以指定选择器:
jsmodule.exports = { darkMode: ['selector', '[data-theme="dark"]'], }
Tailwind v4 怎么写?
Tailwind v4 更偏向在 CSS 里配置。你可以用 @custom-variant 自定义 dark: 的触发条件:
css@import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *));
如果项目用 data-theme 管主题:
css@import "tailwindcss"; @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
页面里继续使用熟悉的 dark: 前缀:
html<section class="rounded-xl border border-slate-200 bg-white p-6 text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100"> <h2 class="text-lg font-semibold">账户设置</h2> <p class="mt-2 text-slate-600 dark:text-slate-400">这里的颜色会跟随主题变化。</p> </section>
原生 JavaScript 如何切换主题?
更稳的做法是保存三种状态:light、dark、system。
jsconst root = document.documentElement; const media = window.matchMedia('(prefers-color-scheme: dark)'); function applyTheme(theme) { const isDark = theme === 'dark' || (theme === 'system' && media.matches); root.classList.toggle('dark', isDark); root.dataset.theme = isDark ? 'dark' : 'light'; } function setTheme(theme) { localStorage.setItem('theme', theme); applyTheme(theme); } const savedTheme = localStorage.getItem('theme') || 'system'; applyTheme(savedTheme); media.addEventListener('change', () => { if ((localStorage.getItem('theme') || 'system') === 'system') applyTheme('system'); });
按钮里调用 setTheme('light')、setTheme('dark') 或 setTheme('system') 即可。
如何避免页面加载时闪一下?
暗色模式闪烁通常叫 FOUC。原因是页面先按亮色渲染,JavaScript 加载后才补上 .dark。解决办法是把一小段脚本放在 <head> 里,尽量早于页面渲染执行:
html<script> try { const theme = localStorage.getItem('theme') || 'system'; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const isDark = theme === 'dark' || (theme === 'system' && prefersDark); document.documentElement.classList.toggle('dark', isDark); document.documentElement.dataset.theme = isDark ? 'dark' : 'light'; } catch (_) {} </script>
如果是 Next.js,next-themes 会省很多事。它已经处理了 SSR、系统偏好、持久化和闪烁问题。
颜色最好用语义化 token 管起来
小页面可以直接写 bg-white dark:bg-slate-950。项目一大,建议把颜色抽成语义化 token,比如背景、前景、卡片、边框、强调色,而不是到处散落 slate-900、gray-800。
css:root { --color-bg: 255 255 255; --color-fg: 15 23 42; --color-card: 248 250 252; --color-border: 226 232 240; } .dark { --color-bg: 2 6 23; --color-fg: 241 245 249; --color-card: 15 23 42; --color-border: 51 65 85; }
组件里使用这些变量:
html<div class="bg-[rgb(var(--color-bg))] text-[rgb(var(--color-fg))]"> <section class="border border-[rgb(var(--color-border))] bg-[rgb(var(--color-card))]">内容</section> </div>
图片、SVG、过渡和可访问性
图标优先用 currentColor,让它继承文字颜色:
html<svg class="h-5 w-5 text-slate-600 dark:text-slate-300" fill="currentColor" viewBox="0 0 20 20"></svg>
亮色和暗色需要不同图片时,可以用两张图切换:
html<img src="logo-light.svg" alt="Logo" class="block dark:hidden" /> <img src="logo-dark.svg" alt="Logo" class="hidden dark:block" />
主题切换时可以加颜色过渡,但不要全站无脑 transition-all。颜色切换 150-250ms 足够,太慢会像页面卡了一下。
暗色模式至少要检查正文、次级文本、占位符、禁用态、按钮 hover、focus ring、错误提示的对比度。不要用纯黑背景配纯白大段文字,长时间阅读会累;深蓝黑或深灰通常更舒服。
测试时别只点一次按钮
建议按这些场景测一遍:首次访问是否跟随系统偏好;手动切换是否保存;刷新页面有没有 FOUC;选择“跟随系统”时系统主题变化是否响应;SSR 页面是否 hydration 一致;hover、active、focus、disabled、error、loading 是否都有暗色样式;Logo、插图、SVG、图表在暗色下是否清晰。
TailwindCSS 暗色模式本身很简单,难的是把触发策略、持久化、闪烁、语义化颜色、第三方组件和可访问性都处理完整。新项目用 v4 可以通过 @custom-variant 管触发条件;v3 项目跟随系统用 media,手动切换用 selector。