Hướng dẫn làm giao diện tối với Zustand


Tuan Duc Tran

This article was written 2 years ago. Information and code examples may be out of date.

Trong bài viết này mình sẽ hướng dẫn các bạn cách làm giao diện tối với Zustand nhé.

Hướng dẫn làm giao diện tối với Zustand

Đầu tiên, hãy tạo dự án của bạn với Next:

npx create-next-app with-zustand-darkmode

Đây là các bước mà mình cấu hình khi tạo dự án Next:

  • Would you like to use TypeScript with this project? … No / Yes
  • Would you like to use ESLint with this project? … No / Yes

Cài đặt Zustand và TailwindCSS

Zustand là giải pháp nhỏ, gọn, nhanh và dễ dàng mở rộng cho vấn đề quản lý trạng thái theo một các rất "gấu 🐻" đời. Thư viện này có API dễ dùng dựa trên các hooks, không hề gập khuôn theo một kiểu mẫu nhất định.

Với YARN

yarn add zustand && yarn add -D tailwindcss postcss autoprefixer

Với PNPM

pnpm add zustand && pnpm add -D tailwindcss postcss autoprefixer

Với NPM

npm i zustand && npm i -D tailwindcss postcss autoprefixer

Chèn vào dự án

Tạo một tệp có tên Theme.js bên trong thư mục components của bạn.

import { useEffect, useLayoutEffect, useRef } from 'react';
import { create } from 'zustand';

export const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

const useSettingTheme = create((set) => ({
  theme: '',
  setTheme: (theme) => set({ theme }),
}));

function update() {
  if (
    localStorage.theme === 'dark' ||
    (!('theme' in localStorage) &&
      window.matchMedia('(prefers-color-scheme: dark)').matches)
  ) {
    document.documentElement.classList.remove('light');
    document.documentElement.classList.add('dark');
  } else {
    document.documentElement.classList.remove('dark');
    document.documentElement.classList.add('light');
  }
}

export function useTheme() {
  let { theme, setTheme } = useSettingTheme();
  let initial = useRef(true);

  useIsomorphicLayoutEffect(() => {
    let theme = localStorage.theme;
    if (theme === 'light' || theme === 'dark') {
      setTheme(theme);
    } else {
      setTheme('system');
    }
  }, []);

  useIsomorphicLayoutEffect(() => {
    if (theme === 'system') {
      localStorage.removeItem('theme');
    } else if (theme === 'light' || theme === 'dark') {
      localStorage.theme = theme;
    }
    if (initial.current) {
      initial.current = false;
    } else {
      update();
    }
  }, [theme]);

  useEffect(() => {
    window
      .matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', update);

    function onStorage() {
      update();
      let theme = localStorage.theme;
      if (theme === 'light' || theme === 'dark') {
        setTheme(theme);
      } else {
        setTheme('system');
      }
    }

    window.addEventListener('storage', onStorage);
    window.addEventListener('load', update);

    return () => {
      window
        .matchMedia('(prefers-color-scheme: dark)')
        .removeEventListener('change', update);

      window.removeEventListener('storage', onStorage);
    };
  }, [setTheme]);

  return [theme, setTheme];
}

Để hiển thị chức năng bật tắt giao diện tối, tại thư mục components bạn tạo tiếp một tệp có tên ThemeToggle.js.

import { useTheme } from './Theme';

let themes = [
  {
    value: 'light',
    label: 'Light',
  },
  {
    value: 'dark',
    label: 'Dark',
  },
  {
    value: 'system',
    label: 'System',
  },
];

function SunIcon({ selected, ...props }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      {...props}
    >
      <path
        d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
        className={
          selected
            ? 'fill-sky-400/20 stroke-sky-500'
            : 'stroke-slate-400 dark:stroke-slate-500'
        }
      />
      <path
        d="M12 4v1M17.66 6.344l-.828.828M20.005 12.004h-1M17.66 17.664l-.828-.828M12 20.01V19M6.34 17.664l.835-.836M3.995 12.004h1.01M6 6l.835.836"
        className={
          selected ? 'stroke-sky-500' : 'stroke-slate-400 dark:stroke-slate-500'
        }
      />
    </svg>
  );
}

function MoonIcon({ selected, ...props }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" {...props}>
      <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M17.715 15.15A6.5 6.5 0 0 1 9 6.035C6.106 6.922 4 9.645 4 12.867c0 3.94 3.153 7.136 7.042 7.136 3.101 0 5.734-2.032 6.673-4.853Z"
        className={selected ? 'fill-sky-400/20' : 'fill-transparent'}
      />
      <path
        d="m17.715 15.15.95.316a1 1 0 0 0-1.445-1.185l.495.869ZM9 6.035l.846.534a1 1 0 0 0-1.14-1.49L9 6.035Zm8.221 8.246a5.47 5.47 0 0 1-2.72.718v2a7.47 7.47 0 0 0 3.71-.98l-.99-1.738Zm-2.72.718A5.5 5.5 0 0 1 9 9.5H7a7.5 7.5 0 0 0 7.5 7.5v-2ZM9 9.5c0-1.079.31-2.082.845-2.93L8.153 5.5A7.47 7.47 0 0 0 7 9.5h2Zm-4 3.368C5 10.089 6.815 7.75 9.292 6.99L8.706 5.08C5.397 6.094 3 9.201 3 12.867h2Zm6.042 6.136C7.718 19.003 5 16.268 5 12.867H3c0 4.48 3.588 8.136 8.042 8.136v-2Zm5.725-4.17c-.81 2.433-3.074 4.17-5.725 4.17v2c3.552 0 6.553-2.327 7.622-5.537l-1.897-.632Z"
        className={
          selected ? 'fill-sky-500' : 'fill-slate-400 dark:fill-slate-500'
        }
      />
      <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M17 3a1 1 0 0 1 1 1 2 2 0 0 0 2 2 1 1 0 1 1 0 2 2 2 0 0 0-2 2 1 1 0 1 1-2 0 2 2 0 0 0-2-2 1 1 0 1 1 0-2 2 2 0 0 0 2-2 1 1 0 0 1 1-1Z"
        className={
          selected ? 'fill-sky-500' : 'fill-slate-400 dark:fill-slate-500'
        }
      />
    </svg>
  );
}

export function ThemeSelect() {
  let [theme, setTheme] = useTheme();

  return (
    <div className="flex items-center justify-between">
      <label
        htmlFor="theme"
        className="font-normal text-slate-700 dark:text-slate-400"
      >
        Switch theme
      </label>
      <div className="relative flex items-center rounded-lg p-2 font-semibold text-slate-700 shadow-sm ring-1 ring-slate-900/10 dark:bg-slate-600 dark:text-slate-200 dark:ring-0">
        <SunIcon
          className="mr-2 h-6 w-6 dark:hidden"
          selected={theme !== 'system'}
        />
        <MoonIcon
          className="mr-2 hidden h-6 w-6 dark:block"
          selected={theme !== 'system'}
        />
        {theme}
        <svg className="ml-2 h-6 w-6 text-slate-400" fill="none">
          <path
            d="m15 11-3 3-3-3"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
        <select
          id="theme"
          value={theme}
          onChange={(e) => setTheme(e.target.value)}
          className="absolute inset-0 h-full w-full appearance-none opacity-0"
        >
          {themes.map(({ value, label }) => (
            <option key={value} value={value}>
              {label}
            </option>
          ))}
        </select>
      </div>
    </div>
  );
}

Sau đó bạn tạo các tệp sau trong thư mục pages

_app.js

import '../styles/tailwind.css';
import React from 'react';

export default function App({ Component, pageProps }) {
  return (
    <>
      <div className="flex flex-col">
        <main className="min-h-screen flex-1">
          <Component {...pageProps} />
        </main>
      </div>
    </>
  );
}

_document.js

import NextDocument, { Head, HTML, Main, NextScript } from 'next/document';

export default class Document extends NextDocument {
  static async getInitialProps(ctx) {
    const initialProps = await NextDocument.getInitialProps(ctx);
    return { ...initialProps };
  }

  render() {
    return (
      <HTML lang="en" className="dark">
        <Head>
          <script
            dangerouslySetInnerHTML={{
              __html: `
                if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
                  document.documentElement.classList.remove('light')
                  document.documentElement.classList.add('dark')
                } else {
                  document.documentElement.classList.remove('dark')
                  document.documentElement.classList.add('light')
                }
              `,
            }}
          />
        </Head>
        <body className="bg-white text-slate-500 antialiased dark:bg-slate-900 dark:text-slate-400">
          <Main />
          <NextScript />
        </body>
      </HTML>
    );
  }
}

index.js

import Head from 'next/head';
import { ThemeSelect } from '../components/ThemeToggle';

export default function IndexPage() {
  return (
    <>
      <Head>
        <title>With Zustand Dark Mode Tailwind</title>
      </Head>
      <div className="relative mx-auto max-w-5xl py-20 sm:py-24 lg:py-32">
        <h1 className="text-center text-4xl font-extrabold tracking-tight text-slate-900 sm:text-5xl lg:text-6xl dark:text-white">
          With Zustand{' '}
          <span className="text-sky-500 dark:text-sky-400">Dark Mode</span>{' '}
          Tailwind
        </h1>
      </div>
      <div className="relative mx-40 mt-6">
        <ThemeSelect />
      </div>
    </>
  );
}

Cuối cùng là các tệp cấu hình để hiển thị css từ Tailwind

tailwind.css

@tailwind base;
@tailwind components;
@tailwind utilities;

postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

tailwind.config.js

module.exports = {
  experimental: {
    optimizeUniversalDefaults: true,
  },
  content: ['./pages/**/*.js', './components/**/*.js'],
  darkMode: ['class', 'html[class~="dark"]'],
  theme: {
    extend: {},
  },
  plugins: [],
};

Source Code GitHub