iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Modern Web

在Vibe Coding 時代一起來做沒有AI感的漂亮網站吧!系列 第 20

UI做出了爆難做的 light/dark mode tab……直接送你react+tailwind寫的元件和售後服務,從此你就是掌管網頁黑夜與白天的神

  • 分享至 

  • xImage
  •  

當初在 Figma 上放入太陽月亮切換的晝夜按鈕,另一個前端就開始唉聲嘆氣。

我當然也有先找網路上的範本,但顏色和風格都跟我網站華麗麗的色調不搭,於是就用生成的圖片搭配動畫自己做一個吧!但卻發現網路上也比較少 React+Tailwind 寫好可用的漂亮元件。

那我們開始囉!

Yes
主要有:

  • React: 用於建構使用者介面的函式庫。
  • Redux Toolkit: 作為全域狀態管理的中心,儲存當前的主題模式。
  • Tailwind CSS
  • React-Redux: 官方的 React 綁定庫,讓我們能在 React 元件中輕鬆存取 Redux store。

第一步:使用 Redux 管理全域主題狀態

我們在 accountSlice.ts 中定義了與使用者帳號相關的狀態,其中就包含了主題偏好設定。

typescript

import { createSlice } from "@reduxjs/toolkit";
// ...const initialState: UserInfoSupa & { email: string } = {
// ... other statessystem_preference_color_mode: "dark",// 預設為暗色模式// ... other states
};

export const accountSlice = createSlice({
  name: "account",
  initialState,
  reducers: {
    setAccount: (state, action) => {
// ...
      state.system_preference_color_mode =
        action.payload.system_preference_color_mode;
// ...
    },
// 關鍵的 Reducer!setDarkMode: (state, action) => {
      state.system_preference_color_mode = action.payload;
    },
  },
});

export const { setAccount, setDarkMode } = accountSlice.actions;
export default accountSlice.reducer;

重點分析:

  1. initialState: 在初始狀態中定義了 system_preference_color_mode,並給予一個預設值(此處為 'dark')。
  2. setDarkMode Reducer: 這是一個專門用來更新主題模式的 action。它接收一個 payload(將會是 'light' 或 'dark'),並直接更新 state。

第二步:UI 切換元件

可以先到這裡下載圖片:

https://lurl.cc/Uv9iv

或者放入自己的圖片

import TemplateMoon from "@/assets/lightDarkIcon/TemplateMoon.webp";
import TemplateWake from "@/assets/lightDarkIcon/TemplateWake.webp";

interface ToggleProps {
  onClick?: () => void;
  isNight?: boolean; // 由外部控制是否為 night 模式
}

export default function ModeToggle({ onClick, isNight = false }: ToggleProps) {
  return (
    <>
      <style>{`
        @keyframes twinkle {
          0% { opacity: 1; }
          100% { opacity: 0.5; }
        }
      `}</style>

      <div
        className={`
          relative h-8 w-24 cursor-pointer rounded-full
          overflow-hidden transition-all duration-300 ease-in-out
          hover:scale-105 active:scale-95 focus:outline-none
          ${
            isNight
              ? "bg-[linear-gradient(145deg,_#1d1f2b,_#16182a)]"
              : "bg-[linear-gradient(145deg,_#357bb3,_#2a6290)]"
          }
        `}
        onClick={onClick}
      >
        {/* Stars */}
        <div
          className={`
            absolute inset-0 transition-all duration-500 ease-in-out
            ${isNight ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-full"}
          `}
        >
          <div className="absolute left-[10%] top-[50%] h-1.5 w-1.5 animate-[twinkle_1s_infinite_alternate] rounded-full bg-slate-300 shadow-[0_0_4px_#fff]" />
          <div className="absolute left-[40%] top-[10%] h-1.5 w-1.5 animate-[twinkle_1s_infinite_alternate] rounded-full bg-slate-300 shadow-[0_0_4px_#fff] rotate-[75deg] scale-110 [animation-delay:300ms]" />
          <div className="absolute left-[40%] top-[60%] h-1.5 w-1.5 animate-[twinkle_1s_infinite_alternate] rounded-full bg-slate-300 shadow-[0_0_4px_#fff] rotate-[150deg] scale-75 [animation-delay:600ms]" />
        </div>

        {/* Sun/Moon */}
        <div
          className={`
            absolute left-1 top-0 h-8 w-8 rounded-full
            transition-all duration-300 ease-in-out
            ${isNight ? "translate-x-[50px] rotate-360" : "translate-x-0 rotate-0"}
          `}
        >
          <img
            src={TemplateWake}
            alt="Sun"
            className={`h-full w-full rounded-full transition-opacity duration-300 ${
              isNight ? "opacity-0" : "opacity-100"
            }`}
          />
          <img
            src={TemplateMoon}
            alt="Moon"
            className={`absolute top-0 left-0 h-full w-full rounded-full transition-opacity duration-300 ${
              isNight ? "opacity-100" : "opacity-0"
            }`}
          />
        </div>
      </div>
    </>
  );
}

重點分析:

  1. Props: 元件接收 onClick 函式和 isNight 布林值。它本身不包含任何邏輯,只負責渲染。
  2. 條件樣式: 透過 isNight prop,我們使用 Tailwind CSS 的模板字串語法來動態切換 class。例如,背景顏色、太陽/月亮圖示的位置和可見度都是這樣控制的,並搭配 transition 實現了流暢的動畫效果。

第三步:連接狀態與 UI

把按鈕放在header ,從父元件寫功能


// ... imports
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store";
import { setDarkMode } from "@/store/slices/accountSlice";
import { usePatchChangeUserInfo } from "@/api";
// ...

export default function Navigator({ setIsShowNotification }: NavigatorProps) {
  const account = useSelector((state: RootState) => state.account);
  const dispatch = useDispatch();
  const { mutate: patchChangeUserInfo } = usePatchChangeUserInfo();

  // 從 Redux store 讀取當前模式
  const isNight = account.system_preference_color_mode === "dark";

  // 處理模式切換的函式
  const handleSwitchMode = () => {
    const newMode = isNight ? "light" : "dark";
    // 1. 派發 action 更新 Redux state
    dispatch(setDarkMode(newMode));
    // 2. 呼叫 API 將使用者偏好存到後端資料庫
    patchChangeUserInfo({
      target: "system_preference_color_mode",
      value: newMode,
      userID: account.user_id,
    });
  };

  return (
    <nav>
      <ul className="flex gap-3 items-center ...">
        {/* ... */}
        <li className="max-sm:hidden ...">
          <ModeToggle onClick={handleSwitchMode} isNight={isNight} />
        </li>
        {/* ... */}
      </ul>
    </nav>
  );
}

  1. useSelector: 我們用 useSelector hook 從 Redux store 中取得 account 狀態,並計算出 isNight 布林值。
  2. useDispatch: 取得 dispatch 函式,以便後續派發 actions。
  3. handleSwitchMode: 這是核心邏輯所在。當使用者點擊 ModeToggle 時:
    • 它首先計算出新的模式 ('light' 或 'dark')。
    • 接著,使用 dispatch(setDarkMode(newMode)) 來更新 Redux store 中的全域狀態。
    • 持久化: 為了讓使用者下次訪問時能記住他的選擇,呼叫了自定義的 usePatchChangeUserInfo hook,它會發送一個 PATCH 請求到後端 API,更新資料庫中該使用者的偏好設定。
  4. Props 傳遞: 最後,將 handleSwitchMode 和 isNight 作為 props 傳遞給 ModeToggle 元件。

第四步:全域應用主題

我們已經可以更新狀態了,但要如何讓整個應用程式的樣式跟著改變呢?在 App.tsx 和 Tailwind CSS 的 dark 模式設定。

typescriptreact

 Show full code block
import Layout from "./components/layout/Layout";
import { useSelector } from "react-redux";
import { RootState } from "./store";
// ...

function App() {
  // ...
  const isDarkMode = useSelector(
    (state: RootState) => state.account.system_preference_color_mode
  );
  // ...

  return (
    // 在根佈局元件上根據 Redux 狀態動態添加 'dark' class
    <Layout className={isDarkMode === "dark" ? "dark" : ""}>
      <Outlet />
      <ScrollRestoration />
    </Layout>
  );
}

export default App;

  1. 讀取狀態App.tsx 作為應用的根元件,同樣使用 useSelector 來訂閱主題模式的狀態。
  2. 動態 Class: 它將 isDarkMode === "dark" ? "dark" : "" 的結果作為 className 傳遞給 Layout 元件。這意味著當 Redux state 中的模式為 'dark' 時,Layout 元件就會擁有 dark 這個 class,從而觸發 Tailwind CSS 的暗黑模式。

Yes

好的就是醬~我們明天繼續講對應晝夜的顏色要怎麼設定!


上一篇
GSAP ScrollTrigger 讓物件隨著滑動說出故事
下一篇
沒有美感也能一鍵生成網頁色彩系統,掌管晝夜模式:Material Theme Builder
系列文
在Vibe Coding 時代一起來做沒有AI感的漂亮網站吧!21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言