OOratiq

Dark mode

Class-driven, token-powered, and free per component.

How it works

Components never know which theme they're in — they read semantic tokens, and the .dark class swaps the token values.

:root  { --background: #ffffff; --foreground: #171717; }
.dark  { --background: #1d1d1d; --foreground: #ffffff; }

/* a component just says: */
<div className="bg-background text-foreground" />

Because the swap happens at the token layer, adding a component never adds dark-mode work. There is no dark:bg-… scattered through component code to keep in sync.

Toggling

The Providers wrapper mounts next-themes; useTheme() flips the class.

"use client";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";

export function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  return (
    <Button
      variant="outline"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
    >
      Toggle theme
    </Button>
  );
}

The details that usually get missed

  • color-scheme is declared per theme, so native controls— date-picker glyphs, file inputs, scrollbars, selection highlights — follow along. CSS can't reach inside a date input; this is the only correct signal.
  • suppressHydrationWarning on <html> is required — the theme class is applied before React hydrates.
  • Dark elevation is surface lift, not heavier shadows: each level up is a lighter background token. Shadows carry little signal on dark surfaces.
  • The dark: Tailwind variant resolves against the class (via @custom-variant), not the OS setting — so the user's in-app choice always wins.

Verify all four modes

Light/dark is half the matrix — the header toggles exist so every change is checked in light/dark and LTR/RTL before it ships.