nextjs•themes

پیاده‌سازی تم روشن و تاریک در Next.js 16 بدون وابستگی

روشی بدون وابستگی برای ساخت سیستم تم روشن و تاریک در Next.js 16 با Tailwind CSS.

Next.jsTailwind CSSReactTypeScriptThemeDark Mode
By Hamid Mohamadi2026-08-31

پیاده‌سازی تم روشن و تاریک در Next.js 16 بدون وابستگی

یک برنامه وب مدرن باید تجربه‌ای راحت و مناسب را در محیط‌های روشن و تاریک فراهم کند.

پشتیبانی از تم اکنون به یک ویژگی مورد انتظار در بیشتر برنامه‌های مدرن تبدیل شده است. کاربران می‌خواهند رابط کاربری با ترجیح سیستم عامل یا انتخاب شخصی آن‌ها هماهنگ باشد.

بسیاری از توسعه‌دهندگان از کتابخانه‌هایی مانند @wrksz/themes استفاده می‌کنند، اما درک نحوه کار سیستم تم در داخل برنامه به ما کمک می‌کند برنامه‌های بهتری بسازیم.

در این مقاله، یک سیستم کامل تم روشن و تاریک را در Next.js 16 بدون نصب هیچ کتابخانه تم خارجی ایجاد می‌کنیم.

چیزی که خواهیم ساخت

سیستم تم ما از موارد زیر پشتیبانی خواهد کرد:

جریان نهایی:

کاربر تم را تغییر می‌دهد
        |
        v
Theme Provider وضعیت را به‌روزرسانی می‌کند
        |
        v
کلاس HTML تغییر می‌کند
        |
        v
متغیرهای CSS به‌روزرسانی می‌شوند
        |
        v
رابط کاربری به‌صورت خودکار تغییر می‌کند

استک پروژه

این پیاده‌سازی از موارد زیر استفاده می‌کند:

نحوه کار تغییر تم

یک سیستم تم بر اساس تغییر کلاس‌ها روی عنصر ریشه HTML کار می‌کند.

<html class="dark">

وقتی کلاس dark وجود داشته باشد، Tailwind استایل‌های حالت تاریک را اعمال می‌کند.

وقتی این کلاس حذف شود، برنامه دوباره به حالت روشن بازمی‌گردد.

تشخیص ترجیح سیستم

مرورگرها یک API داخلی برای تشخیص تم سیستم عامل ارائه می‌دهند.

match-media.ts
window.matchMedia('(prefers-color-scheme: dark)');

این API به برنامه ما اجازه می‌دهد از ترجیح تم سیستم کاربر پیروی کند.

ایجاد متغیرهای CSS

پایه سیستم تم ما متغیرهای CSS هستند.

globals.css
:root {
  --background: #ffffff;
  --foreground: #171717;
}
.dark {
  --background: #0a0a0a;
  --foreground: #ededed;
}

نام متغیرهای CSS یکسان باقی می‌ماند، اما مقادیر آن‌ها بر اساس تم فعال تغییر می‌کند.

اتصال متغیرها به Tailwind

globals.css
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
}

اکنون کامپوننت‌ها از رنگ‌های معنایی استفاده می‌کنند:

<div className="bg-background text-foreground">Content</div>

ایجاد نوع Theme

theme.ts
export type Theme = 'light' | 'dark' | 'system';

ایجاد Theme Provider

Provider وضعیت فعلی تم را مدیریت می‌کند.

theme-provider.tsx
'use client';
 
import { createContext, useContext, useEffect, useState } from 'react';
 
import type { Theme } from '@/lib/theme';
 
type ThemeContextType = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};
 
const ThemeContext = createContext<ThemeContextType | null>(null);
 
function getStoredTheme(): Theme {
  if (typeof window === 'undefined') {
    return 'system';
  }
 
  const stored = localStorage.getItem('theme');
 
  if (stored === 'light' || stored === 'dark' || stored === 'system') {
    return stored;
  }
 
  return 'system';
}
 
function applyTheme(theme: Theme) {
  const root = document.documentElement;
 
  root.classList.remove('light', 'dark');
 
  if (theme === 'system') {
    const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
 
    root.classList.add(dark ? 'dark' : 'light');
 
    return;
  }
 
  root.classList.add(theme);
}
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>(getStoredTheme);
 
  useEffect(() => {
    applyTheme(theme);
 
    localStorage.setItem('theme', theme);
  }, [theme]);
 
  return (
    <ThemeContext.Provider
      value={{
        theme,
        setTheme: setThemeState,
      }}
    >
      {children}
    </ThemeContext.Provider>
  );
}
 
export function useTheme() {
  const context = useContext(ThemeContext);
 
  if (!context) {
    throw new Error('useTheme must be inside ThemeProvider');
  }
 
  return context;
}

اعمال تم روی HTML

تم فعال با اضافه کردن یک کلاس به عنصر ریشه کنترل می‌شود.

apply-theme.ts
document.documentElement.classList.remove('light', 'dark');
 
document.documentElement.classList.add('dark');

ذخیره ترجیح کاربر

انتخاب کاربر باید پس از بارگذاری مجدد صفحه نیز حفظ شود.

theme-storage.ts
localStorage.setItem('theme', 'dark');

خواندن مقدار:

const theme = localStorage.getItem('theme');

مدیریت تم سیستم

system-theme.ts
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

مشکل Hydration

Next.js ابتدا HTML را در سرور رندر می‌کند.

سرور به موارد زیر دسترسی ندارد:

این موضوع می‌تواند باعث خطاهای عدم تطابق Hydration شود.

مثال:

سرور:

<button>
  Sun
</button>

کلاینت:

<button>
  Moon
</button>

حل مشکلات Hydration

رابط کاربری وابسته به کلاینت باید تا زمان mount شدن برنامه روی کلاینت منتظر بماند.

به‌جای دنبال کردن وضعیت mount با useState و useEffect، می‌توانیم با استفاده از useSyncExternalStore یک hook قابل استفاده مجدد ایجاد کنیم.

use-mounted.ts
'use client';
 
import { useSyncExternalStore } from 'react';
 
const emptySubscribe = () => {
  return () => {};
};
 
export function useMounted() {
  return useSyncExternalStore(
    emptySubscribe,
    () => true,
    () => false,
  );
}

این hook موارد زیر را برمی‌گرداند:

این کار از خطاهای عدم تطابق Hydration در React جلوگیری می‌کند.

مثال:

سرور:

<button>
  Sun
</button>

کلاینت:

<button>
  Moon
</button>

ساختار HTML یکسان است، اما محتوا متفاوت است. React این تفاوت را هنگام Hydration تشخیص می‌دهد.

استفاده از Mounted Hook

این hook را می‌توان در کامپوننت‌هایی استفاده کرد که به قابلیت‌های مخصوص مرورگر وابسته هستند.

theme-toggle.tsx
'use client';
 
import { useMounted } from '@/hooks/use-mounted';
 
export function ThemeToggle() {
  const mounted = useMounted();
 
  if (!mounted) {
    return null;
  }
 
  return <button>Dark Mode</button>;
}

اکنون سرور یک نتیجه پایدار رندر می‌کند و کلاینت پس از Hydration کنترل‌های تعاملی تم را رندر می‌کند.

ایجاد Theme Toggle

theme-toggle.tsx
'use client';
 
import { Moon, Sun } from 'lucide-react';
 
import { useTheme } from './theme-provider';
import { useMounted } from '@/hooks/use-mounted';
 
export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
 
  const mounted = useMounted();
 
  if (!mounted) {
    return (
      <button className="rounded-full border p-3" aria-label="Theme toggle" />
    );
  }
 
  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="hover:bg-muted rounded-full border p-3 transition"
      aria-label="Theme toggle"
    >
      {theme === 'dark' ? <Moon size={20} /> : <Sun size={20} />}
    </button>
  );
}

برای سادگی، این toggle فقط بین حالت‌های صریح روشن و تاریک جابه‌جا می‌شود. در یک برنامه production می‌توان گزینه سومی برای ترجیح سیستم نیز ارائه کرد.

ساختار نهایی پروژه

components/
└── theme/
    ├── theme-provider.tsx
    └── theme-toggle.tsx
 
hooks/
└── use-mounted.ts
 
lib/
└── theme.ts
 
app/
└── layout.tsx
 
globals.css

مسئولیت‌ها

بخشمسئولیت
CSS Variablesذخیره رنگ‌های تم
Theme Providerمدیریت وضعیت تم
localStorageذخیره انتخاب کاربر
matchMediaتشخیص تم سیستم
useMounted Hookجلوگیری از عدم تطابق Hydration
Toggle Componentتعامل کاربر

نتیجه‌گیری

ساخت یک سیستم تم بدون وابستگی به ما کمک می‌کند بفهمیم کتابخانه‌هایی مانند @wrksz/themes در داخل چگونه کار می‌کنند.

برای پروژه‌های آموزشی و برنامه‌های کوچک، یک پیاده‌سازی سفارشی سبک است و کنترل کاملی در اختیار ما قرار می‌دهد.

بخش مهم، درک سازوکاری است که در پشت ابزارهایی که استفاده می‌کنیم وجود دارد.