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