2023/05/26
# 我是如何在 Next.js 13 with appDir 設定深色模式按鈕 Dark Mode Switch with Tailwind CSS and Daisy UI
## Dark Mode Switch with Next.js 13
隨著深色模式(Dark Mode)在現代網頁中越來越受歡迎,在網頁中提供Dark Mode切換功能好像是一種越來越常見的設計趨勢。
而Next.js是非常受歡迎的的React網頁框架,在最新版Next.js 13提供appDir作為beta版的功能,其特色是打造更少客戶端(Client)的JS程式碼,建構可重用的Layouts,以目錄作為Routes切換...等等。
由於beta版本的實驗性質,目前在next dev開發網頁深色模式功能時,經常會出現Hydration過程相關的錯誤, 本文將用簡化的例子講解我是如何在Next.js app開發自定義顏色的深色模式按鈕(Dark Mode Switch / Theme Toggle),讓使用者在網頁點擊切換(switch)淺色模式(Light Mode)及深色模式(Dark Mode),在開發時遇到的問題及目前的解決方式。
接下來的文章假設已經設置好Tailwind CSS在Next.js專案中開發。
## 功能需求
- 自定義喜好的主題顏色(Costmize Theme Color),而不只是應用Tailwind CSS預設的Light,Dark 主題顏色。
- 網頁上供使用者自行切換深/淺主題顏色的Toggle按鈕。
- 網頁上Toggle按鈕樣式也會隨著主題切換而改變。
## 開發工具
- Next.js 13 with appDir : 本次開發使用的 React 網頁框架,此版本目前還在實驗階段。
- next-themes : next-themes 套件是一個 Next.js 庫,它提供了一個簡單的 API 來切換深色模式和淺色模式。
- Tailwind CSS : 被廣泛使用的 CSS 框架之一,它提供了一組預定義的樣式類,可以快速構建 UI。
- daisyUI : 是一個基於 Tailwind CSS 的 UI 框架,它提供了許多可複用的 UI 元素。
# Develope
## TailwindCSS config
Tailwind CSS作為CSS框架,依照官方文件說明若要手動切換深色模式,而非依照裝置系統主題顏色偏好,需要使用class strategy而不是media strategy,將`tailwind.config.js`的`darkMode`值修改成:
```
module.exports = {
darkMode: 'class',
// ...
}
```
之後在Html的元素中只要有`dark:`class值出現,表示將會應用到Dark Mode樣式
ex:
```
<div className='bg-orange-50 dark:bg-zinc-800'>
```
## 使用 next-themes
`next-themes`是一個能幫助我們簡化繁瑣設定程序的Next.js套件,提供較為簡易的API來切換Next.js App的theme模式。官方文件在這裡: https://github.com/pacocoursey/next-themes
它的文件寫的相當仔細,也包含Next appDir 最新的應用方式,也比Tailwind CSS文件涵蓋的範圍還要廣,若要自定義主題建議以該文件作為參考主軸。
- 安裝 next-themes
```
$ npm install next-themes
# or
$ yarn add next-themes
```
## 設定 theme provider
```jsx
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider attribute='class'>{children}</ThemeProvider>;
}
```
## 設定 app/layout.tsx
將`Providers`加入`layout.tsx`:
```jsx
// app/layout.tsx
import './globals.css';
import { Providers } from './providers';
import { DarkModeSwitch } from '../components/DarkModeSwitch';
.
.
.
.
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='en' suppressHydrationWarning>
<body>
<Providers>
<DarkModeSwitch />
<div className='bg-orange-50 dark:bg-zinc-800'>
{children}
</div>
</Providers>
</body>
</html>
);
}
```
**注意**
如果不將 suppressHydrationWarning 加到`<html>` 中,網頁控制台將收到警告,因為`next-themes`會更新該元素。此屬性僅應用一層深度,因此不會阻擋其他元素上的Hydration警告。
## 加入深色模式按鈕 (Dark Mode Switch / Theme Toggle with daisyUI)
搞懂前面的設定就已經花費不少時間,讓我們直接使用現成的UI按鈕來點擊切換色彩模式吧!
我使用的是UI套件是daisyUI,它是免費、開源的Tailwind CSS 插件(plugin),優點是輕量、設定簡單、UI樣式現代簡約。
官方文件: https://daisyui.com/docs/install/
- 安裝 daisyUI
```
$ npm install daisyui
# or
$ yarn add daisyui
```
## 設定 daisyUI
- 將 daisyUI 添加到 `tailwind.config.js` 中的插件並加上選擇的主題,例如選擇Daisy UI 提供的 garden 主題:
```
module.exports = {
//...
plugins: [require("daisyui")],
darkMode: 'class',
daisyui: {
themes: ['garden'],
},
}
```
這樣的設定表示Tailwind預設的light theme將應用garden theme組件樣式,而dark theme我們將維持原先Tailwind在元素中樣式名稱prefix寫法(`dark:`)。
更多的主題設定,參考: https://daisyui.com/docs/themes/
為了接下來使用daisyUI `Toggle`組件能夠正常顯示,需要至少選定一個主題,或是將主題設置成`false`,表示應用預設`light theme`組件樣式:
```
module.exports = {
//...
daisyui: {
themes: false,
},
}
```
## 按鈕切換組件 Toggle
在專案中建立`DarkModeSwitch.tsx`組件,我們讓它在網頁中顯示於畫面右上方固定位置。
使用daisyUI Toggle https://daisyui.com/components/toggle/
```jsx
// components/DarkModeSwitch.tsx
'use client';
import { useTheme } from 'next-themes';
export const DarkModeSwitch = () => {
const { theme, setTheme } = useTheme();
//toggle theme
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<div className='fixed z-20 flex w-full justify-end items-end py-3 px-4'>
<span className="label-text">The current theme is {theme}</span>
<input
type='checkbox'
aria-label='Toggle theme'
className='toggle'
onClick={toggleTheme}
/>
</div>
);
};
```
## Hydration 警告
由於我們無法在伺服器端取得theme的值,所以從`useTheme`返回的許多值在mounted到客戶端之前都是未定義的。
如果在mounting到客戶端之前嘗試根據當前theme渲染 UI,我們將看到Hydration不匹配(mismatch UI)錯誤警告視窗:
```
Unhandled Runtime Error
Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching text node for "dark" in <div>.
See more info here: https://nextjs.org/docs/messages/react-hydration-error
Component Stack
div
DarkModeSwitch
./components/DarkModeSwitch.tsx (15:88)
f
./node_modules/.pnpm/next-themes@0.2.1_next@13.3.0_react-dom@18.2.0_react@18.2.0/node_modules/next-themes/dist/index.module.js (8:597)
$
./node_modules/.pnpm/next-themes@0.2.1_next@13.3.0_react-dom@18.2.0_react@18.2.0/node_modules/next-themes/dist/index.module.js (8:348)
Providers
./app/providers.tsx (10:11)
body
html
Call Stack
React
```
**Fix**
我們需要使用 useEffect + setMounted useState 確保組件在客戶端加載:
```jsx
// components/DarkModeSwitch.tsx
'use client';
import React, { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
export const DarkModeSwitch = () => {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
//useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
//toggle theme
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<div className='fixed z-20 flex w-full justify-end items-end py-3 px-4'>
The current theme is {theme}
<input
type='checkbox'
aria-label='Toggle theme'
className='toggle opacity-75 bg-slate-600 dark:bg-cyan-700'
onClick={toggleTheme}
/>
</div>
);
};
```
避免網頁彈出Error: Hydration警告視窗。
相關問題討論:
- https://github.com/pacocoursey/next-themes/issues/152
- https://github.com/pacocoursey/next-themes/issues/169
## 忽略系統主題顏色 Ignore System preference
我們透過`{theme}`變量可以觀察當前系統所應用的主題,分別是system, light, dark,使用者第一次開啟網頁時瀏覽器會先套用system樣式,但是根據我們的`toggleTheme`回呼函數,我們的主題切換是依據`{theme}`的值是light或dark來判斷,因此第一次點擊深色模式按鈕時,網頁畫面主題可能不會改變。為了避免混淆,我們設定忽略系統主題顏色,並讓使用者第一次開啟網頁時瀏覽預設的Light Theme。在ThemeProvider設定屬性`enableSystem={false}`,使`{theme}`值預設為light,使`{theme}`值在light、dark兩個值相互切換:
```
<ThemeProvider enableSystem={false}>
```
## 參考
- https://github.com/pacocoursey/next-themes
- https://www.npmjs.com/package/next-themes?activeTab=readme
- https://tailwindcss.com/docs/dark-mode