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é.
Đầ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:
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
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: [],
};