Dark Mode Strategy ২০২৬ — Modern Web App এ Theming এর সম্পূর্ণ গাইড
FOUC এড়িয়ে, system preference detect করে, এবং performance বজায় রেখে কীভাবে production-grade dark mode বানাবেন — Next.js, Tailwind v4, CSS variables দিয়ে।
Dark mode এখন আর nice-to-have feature না, এখন এটা একটা expected baseline কিন্তু "ভালো" dark mode বানানো যত সহজ মনে হয়, ততটা না! ভুলভাবে implement করলে উপহার হিসেবে যা যা পাবেন:
- Page load-এ এক ঝলকের জন্য সাদা ফ্ল্যাশ (FOUC)
- User-এর preference মনে রাখে না
- System theme পাল্টালে app আপডেট হয় না
- Server-rendered HTML এবং client-rendered output mismatch
আজকের পোস্টে দেখব কীভাবে এই সব সমস্যা একসাথে solve করে একটা production-grade dark mode বানাবেন।
আজকের ব্লগে আমরা cover করব:
- Dark mode-এর তিনটা strategy
- কেন FOUC হয় এবং কীভাবে এড়াবেন
- CSS variables দিয়ে token-based theming
- Server-rendered app-এ (Next.js) সঠিক approach
- System preference automatic respect করা
- User-এর choice persist করা
- Accessibility এবং prefers-color-scheme
Dark Mode-এর তিনটা strategy
প্রায় সব dark mode implementation তিনটার একটা ব্যবহার করে।
১. Class-based (.dark class on root)
HTML root element-এ dark class থাকলে dark theme apply হয়, না থাকলে light।
.button {
background: white;
}
.dark .button {
background: black;
}ভালো: সম্পূর্ণ control, কে dark mode-এ আছে কে নেই JavaScript-এর মাধ্যমে সহজে জানা যায়। খারাপ: Initial render-এ JavaScript-এর জন্য অপেক্ষা করতে হয় — আর এটাই FOUC-এর প্রধান কারণ।
২. Media query-based (prefers-color-scheme)
পুরোপুরি CSS-এর উপর dependence, User-এর OS preference automatic detect হয়।
.button {
background: white;
}
@media (prefers-color-scheme: dark) {
.button {
background: black;
}
}ভালো: No JavaScript, no FOUC, automatic। খারাপ: User manually override করতে পারে না — সবসময় OS preference follow করে।
৩. Hybrid (best of both)
CSS variables দিয়ে token define করুন, JavaScript-এ user choice (system / light / dark) ম্যানেজ করুন। আর এটাই modern recommended approach
:root {
--bg: white;
--fg: black;
}
.dark {
--bg: black;
--fg: white;
}
.button {
background: var(--bg);
color: var(--fg);
}User-কে তিনটা option দিন — System / Light / Dark বাকি সব এই pattern-ই handle করবে।
FOUC কী এবং কেন হয়?
FOUC = Flash of Unstyled Content অথবা এই context-এ FOWT = Flash of Wrong Theme।
User dark mode-এ আছে, কিন্তু হঠাৎ page load হওয়ার ০.৩ সেকেন্ডের জন্য পুরো screen white হয়ে গিয়ে তারপর dark হয় — যা এক কথায় UX disaster
কেন এমন হয়?
- HTML download হয় → server-rendered light mode HTML
- Browser parse করে → light mode দেখায়
- JavaScript execute হয় → localStorage থেকে "dark" পড়ে
- React component-এ class apply হয় → dark mode-এ flip
Step 2 আর step 4-এর মধ্যে time gap — এই gap-এই flash দেখা যায়।
Solution? Step 3-কে JavaScript bundle-এর আগেই execute করতে হবে।
FOUC সম্পূর্ণ এড়ানোর pattern
<head>-এ একটা ছোট blocking script inject করুন — যা React load হওয়ার আগেই run করবে এবং সঠিক class apply করবে।
Next.js App Router-এ implementation
app/layout.tsx:
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
var isDark =
theme === 'dark' ||
(!theme &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}কী হচ্ছে এখানে:
- HTML parse-এর সময়েই এই inline script execute হয় (synchronous, before React)
- localStorage থেকে user-এর saved choice পড়ে
- কিছু না থাকলে OS preference check করে
- Dark হলে
<html class="dark">সাথে সাথে set করে - React render হওয়ার সময় HTML already correct theme-এ
suppressHydrationWarning দরকার — কারণ server-rendered HTML-এ dark class থাকবে না, কিন্তু script আগে এটা যোগ করেছে। এই attribute React-কে বলে "এই mismatch ঠিক আছে"।
এই pattern-এ FOUC প্রায় ১০০% eliminate হয়ে যায়। User কোনো flash দেখবে না এবং সরাসরি correct theme-এ page পাবে।
Theme provider — React-এ state manage
ছোট inline script শুধু initial paint correct করে। কিন্তু user যখন toggle button চাপবে, সেটা React state-এ track করতে হবে। এর জন্য একটা ThemeProvider component বানান।
components/theme-provider.tsx:
"use client";
import * as React from "react";
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: "light" | "dark";
}
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = React.useState<Theme>("system");
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(
"light",
);
// Initial load — read from localStorage
React.useEffect(() => {
const stored = (localStorage.getItem("theme") as Theme) || "system";
setThemeState(stored);
}, []);
// Apply theme whenever it changes
React.useEffect(() => {
const root = document.documentElement;
const systemDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
let resolved: "light" | "dark";
if (theme === "system") {
resolved = systemDark ? "dark" : "light";
} else {
resolved = theme;
}
root.classList.toggle("dark", resolved === "dark");
setResolvedTheme(resolved);
}, [theme]);
// Listen for system theme changes (when theme === "system")
React.useEffect(() => {
if (theme !== "system") return;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
const newResolved = e.matches ? "dark" : "light";
document.documentElement.classList.toggle("dark", newResolved === "dark");
setResolvedTheme(newResolved);
};
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, [theme]);
const setTheme = React.useCallback((newTheme: Theme) => {
localStorage.setItem("theme", newTheme);
setThemeState(newTheme);
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = React.useContext(ThemeContext);
if (!context) throw new Error("useTheme must be used inside ThemeProvider");
return context;
}কী কী handle করছে এই provider:
- Initial load — localStorage থেকে saved theme পড়ে
- Theme change — DOM class update করে, localStorage-এ save করে
- System sync — user যদি "system" choose করে, OS theme পাল্টালে automatic update
- resolvedTheme — actual active theme (যদি user "system" choose করে, এটা "light" বা "dark" বলবে)
Theme toggle component
components/theme-toggle.tsx:
"use client";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "./theme-provider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const options = [
{ value: "light", icon: Sun, label: "Light" },
{ value: "system", icon: Monitor, label: "System" },
{ value: "dark", icon: Moon, label: "Dark" },
] as const;
return (
<div className="inline-flex rounded-full border border-border bg-card p-1">
{options.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
aria-label={`${label} theme`}
className={`
grid place-items-center size-8 rounded-full transition
${
theme === value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}
`}
>
<Icon className="size-4" />
</button>
))}
</div>
);
}এটা ৩-option segmented control — Light / System / Dark। বেশিরভাগ modern app এই pattern-ই follow করে।
CSS Variables — design tokens for theming
Component-এ dark:bg-zinc-900 লেখা মানে যেখানে light-এ color চান, সেখানেও dark-এর color লিখতে হবে। ১০টা component-এ এটা manageable, ১০০টায় nightmare।
Solution: semantic CSS variables।
app/globals.css:
@import "tailwindcss";
@theme {
--color-background: oklch(98% 0.005 130);
--color-foreground: oklch(20% 0.02 130);
--color-card: oklch(100% 0 0);
--color-card-foreground: oklch(20% 0.02 130);
--color-muted: oklch(94% 0.01 130);
--color-muted-foreground: oklch(45% 0.02 130);
--color-border: oklch(88% 0.01 130);
--color-primary: oklch(55% 0.16 145);
--color-primary-foreground: oklch(98% 0.005 130);
}
@layer base {
.dark {
--color-background: oklch(15% 0.015 130);
--color-foreground: oklch(95% 0.005 130);
--color-card: oklch(20% 0.015 130);
--color-card-foreground: oklch(95% 0.005 130);
--color-muted: oklch(25% 0.015 130);
--color-muted-foreground: oklch(65% 0.015 130);
--color-border: oklch(28% 0.015 130);
--color-primary: oklch(70% 0.16 145);
--color-primary-foreground: oklch(15% 0.015 130);
}
}এবার component-এ:
<div class="bg-card text-card-foreground border-border">
<h2 class="text-foreground">Title</h2>
<p class="text-muted-foreground">Description</p>
</div>কোথাও **dark:* ** prefix লাগেনি। Theme change-এ value-গুলো automatic switch হয়।
Dark mode color choose করার technique
Dark mode design করার সময় কিছু rules মাথায় রাখুন:
১. Pure black এড়িয়ে চলুন
#000000 ব্যবহার করবেন না — eye strain হয়। মনিটরে contrast এত বেশি যে চোখ সারাক্ষণ refocus করতে হয়।
/* ❌ এটা না */
--color-background: #000000;
/* ✅ এটা ভালো */
--color-background: oklch(15% 0.015 130); /* near-black with tint */আদর্শ dark background এর lightness প্রায় ৮%-১৫% এর মধ্যে।
২. Subtle color tint যোগ করুন
পুরো ধূসর-কালো monochrome boring লাগে। Background-এ একটু color tint দিন — দেখতে অনেক premium লাগবে।
/* Pure gray — flat */
--color-background: oklch(15% 0 0);
/* Slight green tint — depth feel */
--color-background: oklch(15% 0.015 130);৩. Light mode-এর color flip করবেন না
Light-এ #f0f9ff (light blue) ব্যবহার করেছেন বলে dark-এ এটার "opposite" বানাবেন না। Dark mode-এর জন্য আলাদাভাবে design করুন।
OKLCH ব্যবহার করলে শুধু lightness flip করেই অনেক ক্ষেত্রে সুন্দর result পাওয়া যায়:
:root {
--primary: oklch(55% 0.18 250);
}
.dark {
--primary: oklch(70% 0.18 250);
} /* same hue, brighter */৪. Saturation কমান
Dark background-এ over-saturated color চোখকে "burn" করে দেয়। Light mode-এর 0.25 chroma dark mode-এ 0.18 -এ নামালে অনেক comfortable লাগবে।
Accessibility — prefers-reduced-motion ও contrast
Dark mode setup করার সময় accessibility-ও চিন্তা মাথায় রাখুন।
Smooth theme transition (carefully)
Toggle button চাপলে সাথে সাথে jarring color flash কেউ চায় না কিন্তু blind transition-ও problematic — কারণ user যদি reduced motion preference set করে, তাহলে সেক্ষেত্রে animation skip করা উচিত।
:root {
/* No transition by default */
}
@media (prefers-reduced-motion: no-preference) {
*,
*::before,
*::after {
transition:
background-color 200ms ease,
border-color 200ms ease,
color 200ms ease;
}
}prefers-reduced-motion: no-preference মানে user reduced motion enable করেননি, আর এই query match করলেই transition apply হবে।
Contrast check
WCAG AA standard:
- Body text — 4.5:1 minimum
- Large text (18px+) — 3:1 minimum
- UI components — 3:1 minimum
Dark mode-এ contrast verify করতে WebAIM Contrast Checker ব্যবহার করুন। OKLCH দিয়ে design করলে contrast predict করা সহজ — same lightness gap মানে same contrast।
একটা সম্পূর্ণ working example
পুরো setup-টা একসাথে। app/layout.tsx:
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const themeInitScript = `
(function() {
try {
var theme = localStorage.getItem('theme');
var isDark =
theme === 'dark' ||
(!theme &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}আপনার page-এ:
import { ThemeToggle } from "@/components/theme-toggle";
export default function Page() {
return (
<div className="bg-background text-foreground min-h-screen p-8">
<nav className="flex justify-between items-center mb-8">
<h1 className="font-serif text-2xl">My Site</h1>
<ThemeToggle />
</nav>
<div className="bg-card text-card-foreground p-6 rounded-card border border-border">
<h2 className="text-foreground text-xl mb-2">Card Title</h2>
<p className="text-muted-foreground">
এই card automatic dark mode-এ flip হবে।
</p>
</div>
</div>
);
}সব কিছু — FOUC prevention, theme persistence, system sync, semantic styling — একসাথে সব handled
যে ভুলগুলো অনেকে করে, তার কিছু নমুনা
ভুল ১: useEffect-এ শুধু theme পড়া
// ❌ এটা FOUC তৈরি করবে
useEffect(() => {
const theme = localStorage.getItem("theme");
if (theme === "dark") document.documentElement.classList.add("dark");
}, []);useEffect runs after initial render — মানে user already light mode দেখে ফেলেছে। এই ক্ষেত্রে Inline blocking script ব্যবহার করুন।
ভুল ২: System change ignore করা
User যদি "system" mode choose করে এবং তারপর তাদের OS dark থেকে light-এ flip করে — আপনার app should also flip matchMedia listener add না করলে এটা miss হবে। আমাদের ThemeProvider-এ এই listener ব্যবহার করা হয়েছে।
ভুল ৩: SSR-এ default theme assume করা
Server-এ আপনি জানেন না user dark mode-এ আছে নাকি light-এ কারণ localStorage server-এ নেই। তাই server-rendered HTML সবসময় একটা default (সাধারণত light) দিয়ে যাবে। এর মানে server output আর client output প্রথম render-এ differ করতে পারে।
এই ক্ষেত্রে suppressHydrationWarning দরকার <html>-এ।
ভুল ৪: Cookie ভুলে যাওয়া (যদি SSR critical হয়)
যদি আপনার app-এর জন্য একদম zero flash চান এবং Server-side রেন্ডারিং-এ correct theme send করতে চান, তাহলে localStorage না বরং cookie ব্যবহার করুন। কেননা Cookie server-এ সহজে পাঠানো হয়, server ও সহজে জানতে পারে user-এর preference
// Save to cookie instead
document.cookie = `theme=${theme}; path=/; max-age=31536000`;Server-side-এ:
import { cookies } from "next/headers";
export default async function RootLayout({ children }) {
const theme = (await cookies()).get("theme")?.value || "system";
const initialClass = theme === "dark" ? "dark" : "";
return (
<html lang="en" className={initialClass}>
...
</html>
);
}এই pattern-এ inline script-ও লাগে না — HTML already correct।
কখন কোন approach
| Use case | Strategy |
|---|---|
| Simple static site | prefers-color-scheme only |
| App with user accounts | Hybrid (class-based + localStorage) |
| Marketing site (perf critical) | Cookie-based SSR |
| Blog (যেমন Notesaid24) | Hybrid (class + localStorage) ✅ |
| Internal dashboard | Hybrid |
আপনার app-এর nature বুঝে choose করুন।
সংক্ষেপে
- Hybrid approach (class + localStorage + media query) ২০২৬-এ standard
- Inline blocking script ছাড়া FOUC এড়ানো অসম্ভব
- Semantic CSS variables ব্যবহার করুন — ** dark:* ** prefix-এর দরকার নেই
- System changes listen করুন matchMedia দিয়ে
- OKLCH color space dark mode-এর জন্য আদর্শ
- Pure black এড়িয়ে চলুন — eye strain হয়
- Contrast check করুন WCAG AA minimum
- Cookie-based approach SSR-এ zero flash দেয়
- suppressHydrationWarning
<html>-এ লাগবে — এখানে hydration mismatch normal
ভালো dark mode user দেখলে notice করে না — শুধু feel করে যে app comfortable. যেদিন কেউ আপনার site-এ "dark mode টা ভালো হয়েছে" তা না বলে, কিন্তু রাতে এক ঘণ্টা ধরে পড়বে — সেদিন বুঝবেন আপনি right পাথে আছেন।
আপনার পরের project-এ এই pattern try করুন। next-themes library থাকলেও থাকুক কিন্তু কীভাবে কাজ করছে সেটা জানা থাকলে debug অনেক সহজ। আর library দিয়ে customize-এ কখনো আটকে যাবেন না।
আরও পড়ুন
- OKLCH Color এর সম্পূর্ণ গাইড — dark mode color picking এর foundation
- Tailwind v4 Design Tokens — semantic theming system
- CSS Alpha Value — transparent overlays in dark mode
Written by
Ahshan Habib
Full-stack developer. লেখেন TypeScript, CSS, এবং web design নিয়ে।
@AhshanHabibKeep reading
OKLCH Color কী? RGB ও HSL ছেড়ে কেন এই নতুন color format ব্যবহার করবেন
OKLCH হলো CSS-এর সবচেয়ে আধুনিক color format। কেন Tailwind v4, Radix, এবং modern design system এটা ব্যবহার করছে — সব বুঝবেন এই গাইডে।
Tailwind CSS v4-এ Design Tokens — Modern CSS দিয়ে Production-Ready System বানান
Tailwind v4-এ design tokens এর নতুন approach। @theme directive, CSS variables, এবং কীভাবে একটা scalable design system বানাবেন — সম্পূর্ণ গাইড।