如何优雅的支持深色模式
如何优雅的支持深色模式
适配深色模式的网站示例
CSR Only
如果只是客户端渲染的网页的话,事情还是很简单的。基本思路如下:
首先使用 jotai 定义一份用户偏好的全局设置,持久化到存储中。
const themeOptions = ["system", "light", "dark"] as const
export type Theme = (typeof themeOptions)[number]
const appearanceAtom = atomWithStorage<Theme>("use-dark", "system")
const themeOptions = ["system", "light", "dark"] as const
export type Theme = (typeof themeOptions)[number]
const appearanceAtom = atomWithStorage<Theme>("use-dark", "system")
基于 jotai 的 atom 来实现自定义 hook useDark
。
综合用户和系统的选择来判断网页是否是深色,同时同步状态到 html 节点的 class 属性中。
值得一提的是,当用户偏好和系统偏好保持一致时,我们需要更新用户偏好,以让网页恢复跟随系统偏好。
export function useDark() {
const [setting, setSetting] = useAtom(appearanceAtom)
const isDark = useMedia("(prefers-color-scheme: dark)")
useEffect(() => {
const isDarkMode = setting === "dark" || (isDark && setting !== "light")
if (isDarkMode) {
document.documentElement.classList.toggle("dark", true)
} else {
document.documentElement.classList.toggle("dark", false)
}
if ((setting === "dark" && isDark) || (setting === "light" && !isDark)) {
setSetting("system")
}
}, [setting, isDark, setSetting])
const toggleDark = () => {
if (setting === "system") {
setSetting(isDark ? "light" : "dark")
} else {
setSetting("system")
}
}
return [
setting === "dark" || (isDark && setting !== "light"),
toggleDark,
] as const
}
export function useDark() {
const [setting, setSetting] = useAtom(appearanceAtom)
const isDark = useMedia("(prefers-color-scheme: dark)")
useEffect(() => {
const isDarkMode = setting === "dark" || (isDark && setting !== "light")
if (isDarkMode) {
document.documentElement.classList.toggle("dark", true)
} else {
document.documentElement.classList.toggle("dark", false)
}
if ((setting === "dark" && isDark) || (setting === "light" && !isDark)) {
setSetting("system")
}
}, [setting, isDark, setSetting])
const toggleDark = () => {
if (setting === "system") {
setSetting(isDark ? "light" : "dark")
} else {
setSetting("system")
}
}
return [
setting === "dark" || (isDark && setting !== "light"),
toggleDark,
] as const
}
应用自定义 hook 到主题切换的按钮和需要对接深色模式状态的组件上即可。
可以看到,一切都很自然,客户端渲染的特性让我们不会看到还不完整的界面。
在渲染组件时触发的 useEffect
会正确同步 dark 信息到 html 的 class 标签上,界面不会有深浅模式切换的闪烁。
对于 Vue.js 的开发者,可以直接使用 vueuse 中包含的 useDark↗ 函数。 需要指出,这个函数返回的状态并非全局状态。
支持 Electron
要支持 Electron,在有可调用的 API 的情况下,我们需要同步软件主题偏好。
if (window.electron) {
window.electron.setTheme(
setting === "system" ? (isDark ? "light" : "dark") : "system",
)
}
if (window.electron) {
window.electron.setTheme(
setting === "system" ? (isDark ? "light" : "dark") : "system",
)
}
移除重置主题的操作
if (!window.electron) {
if ((setting === "dark" && isDark) || (setting === "light" && !isDark)) {
setSetting("system")
}
}
if (!window.electron) {
if ((setting === "dark" && isDark) || (setting === "light" && !isDark)) {
setSetting("system")
}
}
不过,更推荐的是隐藏掉系统标题栏,手动绘制,这样不必特地适配。 查看 Electron 深色模式支持↗
考虑 SSR
如果我们将上面的逻辑直接迁移到 Next.js 的话,就会出现闪烁的问题。
原因在于 Next.js 返回的首屏网页是完整内容的 html,浏览器已经可以直接加载出界面。 然而在服务端,我们无法提前知道用户浏览器的外观偏好设置。 当浏览器的偏好和返回的 html 设定不一致的时候,触发状态的同步就会导致浏览器界面闪烁。
如何解决
- 使用第三方库 next-themes↗ 直接解决
- 在网站返回的 html 实际内容注入脚本,在页面加载之前执行来确保网页显示的主题正确
支持 Next.js 13?
在 Next.js 13 版本中,app 目录下的组件默认为服务端组件,next-themes 的 provider 或是我们自己的脚本,都需要为客户端组件。
参考 Rendering third-party context providers in Server Components↗ 了解更多
"use client"
export default function Provider({ children }: { children: React.ReactNode }) {
return (
<>
<script
id="change-theme"
dangerouslySetInnerHTML={{
__html: `!function(){var e=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches,t=localStorage.getItem("use-dark")||"system";('"dark"'===t||e&&'"light"'!==t)&&document.documentElement.classList.toggle("dark",!0)}()`,
}}
></script>
{children}
</>
)
}
"use client"
export default function Provider({ children }: { children: React.ReactNode }) {
return (
<>
<script
id="change-theme"
dangerouslySetInnerHTML={{
__html: `!function(){var e=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches,t=localStorage.getItem("use-dark")||"system";('"dark"'===t||e&&'"light"'!==t)&&document.documentElement.classList.toggle("dark",!0)}()`,
}}
></script>
{children}
</>
)
}
顺便一提,jotai 在将字符串存储到 storage 里面时,会加上 “”。 因此,上面的脚本中,当我们手动取值时,实际取出的内容需要补上它。
此外,由于脚本会修改 html 标签上的 class 属性,导致客户端的网页和浏览器的网页内容不一致。
我们需要为 html 标签加上 suppressHydrationWarning
属性来告诉 Next.js 在激活时忽略它。
一点点动画
首先我们可以给整个网站在深浅色切换时的颜色过渡加上一点点 transition。transition-colors duration-500
然后就是切换主题的按钮,深浅色图标切换时加上一点旋转的动画。
export default function AppearanceSwitch() {
const [, toggleDark] = useDark()
return (
<button onClick={toggleDark} className="text-2xl flex">
<div className="i-carbon-sun rotate-0 scale-100 transition-transform duration-500 dark:-rotate-90 dark:scale-0" />
<div className="i-carbon-moon absolute rotate-90 scale-0 transition-transform duration-500 dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</button>
)
}
export default function AppearanceSwitch() {
const [, toggleDark] = useDark()
return (
<button onClick={toggleDark} className="text-2xl flex">
<div className="i-carbon-sun rotate-0 scale-100 transition-transform duration-500 dark:-rotate-90 dark:scale-0" />
<div className="i-carbon-moon absolute rotate-90 scale-0 transition-transform duration-500 dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</button>
)
}