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.
User clicks toggle
theme state flips light ↔ dark
classList updated
classList.toggle('dark') on <html>
Tailwind activates
All dark: variants become active
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 */ @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.
"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.
// ✅ 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" )} />
| Purpose | Light | Dark |
|---|---|---|
| Card / panel | bg-white | dark:bg-[#161b22] |
| Inner surface | bg-slate-50 | dark:bg-[#0d1117] |
| Border | border-slate-200 | dark:border-[#1f2937] |
| Primary text | text-slate-900 | dark:text-white |
| Secondary text | text-slate-500 | dark:text-slate-400 |
| Muted text | text-slate-400 | dark:text-slate-500 |
| Divider | divide-slate-100 | dark: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 — 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'); } })(); `, }} />