DEV Community

Cover image for Implementasi Theme Switcher di NextJS Tanpa Ribet, Flicker Free + Persist via localStorage Pakai Context API.
Selja Sampe Rante
Selja Sampe Rante

Posted on

Implementasi Theme Switcher di NextJS Tanpa Ribet, Flicker Free + Persist via localStorage Pakai Context API.

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:*.
tailwidn dark variant usage

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!

code

That it, nggak usah ngetik dark: lagi. Warna otomatis ngikutin class tema yang kita set.

Hasil gif Contoh sederhana

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.

code image

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Step 1. Setup Context-nya

Kita setup contextnya di folder app

code image

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:

  1. Bikin ThemeContext
  2. Bikin ThemeProvider
  3. 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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

Jan Lupa export yang diperlukan ygy.

export { ThemeProvider, THEMES, useTheme };
export type { ThemeType };
Enter fullscreen mode Exit fullscreen mode

Pasang ThemeProvider di layout

Kita masukin ThemeProvidernya RootLayout

 return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased `}
      >
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

code image


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>
  );
Enter fullscreen mode Exit fullscreen mode

code image


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>
  );
Enter fullscreen mode Exit fullscreen mode

🔗 Source Code

GitHub


Terima kasih sudah baca sampai akhir, semoga bermanfaat.

Top comments (0)