Try   HackMD

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.jsdarkMode值修改成:

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

// 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:

// 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/

// 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 確保組件在客戶端加載:

// 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警告視窗。
相關問題討論:

忽略系統主題顏色 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}>

參考