Manual

Implement dark mode manually in your project without using a library.

Manual dark mode implementation

If you are not using a framework-specific theme library, you can manage dark mode yourself by storing the selected theme in localStorage, resolving the system option with matchMedia, and toggling classes on document.documentElement.

Configure the inline script

Add the following script to the <head> of your main HTML layout. This applies the correct theme before the page renders and helps avoid a flash of the wrong theme.

src/layouts/root.html
123456789101112131415161718192021222324252627282930
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(() => {
const storedTheme = localStorage.getItem("theme");
const theme =
storedTheme === "light" || storedTheme === "dark" || storedTheme === "system"
? storedTheme
: "system";
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const resolvedTheme = theme === "system" ? systemTheme : theme;
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(resolvedTheme);
})();
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

Create a theme helper

Create a small helper to read and apply the current theme from one place.

src/lib/theme.ts
1234567891011121314151617181920212223242526
export type Theme = "light" | "dark" | "system";
const STORAGE_KEY = "theme";
export function getStoredTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
return "system";
}
export function applyTheme(theme: Theme) {
const root = document.documentElement;
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const resolvedTheme = theme === "system" ? systemTheme : theme;
root.classList.remove("light", "dark");
root.classList.add(resolvedTheme);
localStorage.setItem(STORAGE_KEY, theme);
}

Add a Theme Toggle

You can use a standalone ThemeToggle component that updates the root classes directly without requiring a provider or external package.

src/components/ThemeToggle.tsx
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
import { useEffect, useState } from "react";
import { Sun, Monitor, Moon } from "lucide-react";
import { cn } from "@/lib/utils";
import { applyTheme, getStoredTheme, type Theme } from "@/lib/theme";
const LENS_POSITIONS: Record<Theme, string> = {
light: "translate-x-0",
system: "translate-x-full",
dark: "translate-x-[200%]",
};
const THEME_OPTIONS = [
{ value: "light", icon: Sun, label: "Light theme" },
{ value: "system", icon: Monitor, label: "System theme" },
{ value: "dark", icon: Moon, label: "Dark theme" },
] as const;
export default function ThemeToggle({ className }: { className?: string }) {
const [mounted, setMounted] = useState(false);
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = getStoredTheme();
setTheme(stored);
const timer = setTimeout(() => setMounted(true), 0);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!mounted) return;
applyTheme(theme);
}, [theme, mounted]);
return (
<div
className={cn(
"relative border border-glass-border shadow-glass-sm w-[97px] h-8 flex items-center rounded-glass-sm",
className
)}
role="radiogroup"
aria-label="Theme toggle"
>
<div
className={cn(
"absolute w-8 h-8 rounded-glass-sm z-0 glass",
mounted ? "transition-transform duration-300 cubic-bezier(0.4, 0.0, 0.2, 1)" : "",
LENS_POSITIONS[theme]
)}
/>
{THEME_OPTIONS.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={cn(
"flex z-10 h-8 w-8 rounded-glass-sm transition-colors duration-200 justify-center items-center",
theme === value ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)}
aria-label={label}
role="radio"
aria-checked={theme === value}
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
);
}

Keep system mode in sync

If you support the system option, listen for operating system theme changes and re-apply the theme when needed.

src/components/ThemeToggle.tsx
12345678910111213141516
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (theme === "system") {
applyTheme("system");
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [theme]);