Background LightBackground Dark
Tuan Duc DesignTuan Duc Design

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

Ngày đăng

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 { create } from 'zustand'
import { useEffect, useRef, useLayoutEffect } from 'react'

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 p-2 font-semibold rounded-lg shadow-sm text-slate-700 ring-1 ring-slate-900/10 dark:bg-slate-600 dark:text-slate-200 dark:ring-0">
        <SunIcon className="w-6 h-6 mr-2 dark:hidden" selected={theme !== 'system'} />
        <MoonIcon className="hidden w-6 h-6 mr-2 dark:block" selected={theme !== 'system'} />
        {theme}
        <svg className="w-6 h-6 ml-2 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 w-full h-full opacity-0 appearance-none"
        >
          {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="flex-1 min-h-screen">
          <Component {...pageProps} />
        </main>
      </div>
    </>
  )
}

_document.js

import NextDocument, { Html, Head, 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="antialiased bg-white text-slate-500 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 max-w-5xl py-20 mx-auto sm:py-24 lg:py-32">
        <h1 className="text-4xl font-extrabold tracking-tight text-center text-slate-900 dark:text-white sm:text-5xl lg:text-6xl">
          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