GitHub

Dark Mode

All components ship with dark: variants out of the box. You just need to set up class-based dark mode in your project and add a toggle.

How It Works

Adding the dark class to <html> activates all dark: variants across every installed component.

01

User clicks toggle

theme state flips light ↔ dark

02

classList updated

classList.toggle('dark') on <html>

03

Tailwind activates

All dark: variants become active

04

localStorage saved

Preference persists across sessions

Setup

Add @custom-variant dark to your globals.css. This tells Tailwind to activate dark: variants when the dark class is on any ancestor element.

globals.css
/* globals.css */
@custom-variant dark (&:is(.dark *));

If you ran npx react-principles@latest init, this line is already added to your globals.css.

Theme Toggle

A minimal toggle component using React state. On mount it reads from localStorage and falls back to the OS preference. Adapt this to your state management solution of choice.

components/ThemeToggle.tsx
"use client";

import { useEffect, useState } from "react";

export function ThemeToggle() {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  // Init: read localStorage → fallback to system preference
  useEffect(() => {
    const stored = localStorage.getItem("theme");
    if (stored === "dark" || stored === "light") {
      setTheme(stored);
    } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
      setTheme("dark");
    }
  }, []);

  // Sync state → <html> class + localStorage
  useEffect(() => {
    const root = document.documentElement;
    root.classList.toggle("dark", theme === "dark");
    localStorage.setItem("theme", theme);
  }, [theme]);

  const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"));

  return (
    <button onClick={toggle} aria-label="Toggle theme">
      {theme === "dark" ? "☀️" : "🌙"}
    </button>
  );
}

Writing Dark Styles

Add dark: variants alongside the light class. Use cn() for conditional merging.

example.tsx
// ✅ Standard dark mode — use dark: prefix
<div className="bg-white dark:bg-[#161b22]">
  <h2 className="text-slate-900 dark:text-white">Title</h2>
  <p className="text-slate-500 dark:text-slate-400">Subtitle</p>
  <div className="border border-slate-200 dark:border-[#1f2937]" />
</div>

// ✅ Conditional with cn()
import { cn } from "@/lib/utils";

<div className={cn(
  "rounded-lg border p-4",
  "bg-white dark:bg-[#161b22]",
  "border-slate-200 dark:border-[#1f2937]",
  isActive && "ring-2 ring-primary"
)} />
PurposeLightDark
Card / panelbg-whitedark:bg-[#161b22]
Inner surfacebg-slate-50dark:bg-[#0d1117]
Borderborder-slate-200dark:border-[#1f2937]
Primary texttext-slate-900dark:text-white
Secondary texttext-slate-500dark:text-slate-400
Muted texttext-slate-400dark:text-slate-500
Dividerdivide-slate-100dark:divide-[#1f2937]

Gotchas

Theme flash on hard reload

Since the theme is initialised in a React useEffect, there may be a brief flash on first paint if the stored preference is dark. Fix it by inlining a blocking script in <head> that reads localStorage before React hydrates.

app/layout.tsx
// app/layout.tsx — add this script to <head> to prevent flash
<script
  dangerouslySetInnerHTML={{
    __html: `
      (function() {
        var theme = localStorage.getItem('theme');
        if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
          document.documentElement.classList.add('dark');
        }
      })();
    `,
  }}
/>
react-principles