Halo, kali ini gua mau share ini karena gua pengen coba implement Theme Switcher di nextjs tapi yg tetep bisa terintegrasi sama tailwind.
Cara pertama gua coba ngikutin di tailwind docs pake variant dark:*.

Tapi makin lama, nulis variant dark: berulang-ulang itu bikin capek. Class jadi gemuk, dan kode jadi keliatan berantakan.
Pas gua ngulik-ngulik, gua nemu cara yang jauh lebih simpel dan lebih “manusiawi”. Nggak perlu variant, nggak perlu trik aneh, dan bisa punya banyak tema sekaligus, nggak cuma light & dark tapi tetep full terintegrasi sama Tailwind.
Cara gantinya cuma 1 baris coyy!
That it, nggak usah ngetik
dark:lagi. Warna otomatis ngikutin class tema yang kita set.
Full Code Ada di Akhir.
Buat Project Nextjsnya
Kali ini gua pake Next.js 15 + Tailwind v4 (kalau ada yang request, gua siap bikinin versi Tailwind v3). Semuanya tanpa install library tambahan.
Karena projectnya simpel, gua cukup pake Context API sebagai state management.
Step 0. Siapin Warna Tema
Sebagai contoh, gua bikin 3 tema: default/light, dark, dan green.
//global.css
:root {
--background: #fbfbfe;
--text: #040316;
--primary: #2f27ce;
--secondary: #dddbff;
--accent: #443dff;
}
.dark-theme {
--background: #010104;
--text: #eae9fc;
--primary: #3a31d8;
--secondary: #020024;
--accent: #0600c2;
}
.green-theme {
--background: #ddfdeb;
--text: #033018;
--primary: #098b43;
--secondary: #97dee3;
--accent: #5fb9d5;
}
Jan lupa kita set di tailwind confignya.
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--text);
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--color-accent: var(--accent);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
Step 1. Setup Context-nya
Kita setup contextnya di folder app
Kasih prefix underscore agar foldernya diabaikan sama routing nextjs(optional)
Folder konteks gua taruh di app/_context biar aman dari routing Next.js.
Struktur kerjanya sederhana:
- Bikin ThemeContext
- Bikin ThemeProvider
- Bikin useTheme()
Setup ThemeContext + Types
const THEMES = ["default", "dark-theme", "green-theme"] as const;
type ThemeType = (typeof THEMES)[number];
type ThemeContextType = {
theme: ThemeType;
setTheme: (theme: ThemeType) => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
Kenapa kita kasih as const di var themesnya? nanti saya buat penjelasan detail nya yaa. Intinya biar jadi readonly dan tiap item dianggap literal type, bukan string biasa, jadinya lebih enak dipakai.
Buat isi ThemeProvider
Nah ini part yang paling penting.
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<ThemeType>("default");
//value tema berubah -> remove semua theme class di body -> kalo bukan theme default tambah data theme yang terbaru ke body class.
useEffect(() => {
const body = document.body;
body.classList.remove(...THEMES);
if (theme !== "default") {
body.classList.add(theme);
}
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
Logiknya simpel: kita cuma ganti class pada body htmlnya. CSS var yang dipake tailwind yang sudah kita set bakal otomatis berubah.
Bikin Hook useTheme()
Biar bisa langsung di pake di component.
const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
Jan Lupa export yang diperlukan ygy.
export { ThemeProvider, THEMES, useTheme };
export type { ThemeType };
Pasang ThemeProvider di layout
Kita masukin ThemeProvidernya RootLayout
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased `}
>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
Pemakaian
Pilih Tema Manual
export default function Home() {
const { theme, setTheme } = useTheme();
return (
<div className="...">
...
<div className=" flex gap-4">
{THEMES.map((t) => (
<button
key={t}
className="..."
onClick={() => setTheme(t)}
>
{t === theme ? "✓ " : ""}
{t}
</button>
))}
</div>
...
</div>
Toggle Dark Mode
const handleToggle = () => {
setTheme(theme == "default" ? "dark-theme" : "default");
};
return (
<div className="...">
...
<div className="...">
<label className="...">
<span className="...">
Light Theme
</span>
<input
type="checkbox"
value={theme}
className="sr-only peer"
onChange={handleToggle}
checked={theme === "dark-theme"}
/>
<div className="..."></div>
<span className="...">
Dark Theme
</span>
</label>
</div>
...
</div>
);
Persist Via localStorage (Optional)
Kita tinggal refactor ThemeContextnya agar make dan nyimpen theme statenya di localStorage.
// kita ganti default value statenya dari localStorage kalau ada
const [theme, setTheme] = useState<ThemeType>(() => {
//karena saat pertama render itu di server, kita buat fallback defaultnya
if (typeof window === "undefined") return "default";
//kalau sudah run di client baru kita akses localStoragenya
const savedTheme = localStorage.getItem("theme") as ThemeType | null;
return savedTheme && THEMES.includes(savedTheme) ? savedTheme : "default";
});
//untuk ngefix hydration error
//karena perbedaan theme di server dan di client
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
localStorage.setItem("theme", theme);
}, [theme]);
...
//kita hanya merender uinya jika run di client
if (!mounted) return null;
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
🔗 Source Code
Terima kasih sudah baca sampai akhir, semoga bermanfaat.






Top comments (0)