Proper UI

Dark mode

Easily swap between light and dark modes with CSS variables.

Dark mode support

Dark mode in Proper UI is class-based, not media-query based. globals.css declares a custom variant:

@custom-variant dark (&:where(.dark-mode, .dark-mode *));

so adding .dark-mode anywhere in the ancestor chain flips every semantic token defined in theme.css's .dark-mode { ... } block. A component written with semantic tokens (bg-primary, text-primary, border-secondary) is automatically correct in dark mode and needs zero dark: utilities of its own.

Reach for an explicit dark: utility only for the rare case a token can't express (swapping an image asset, inverting a logo mark, or a gradient stop with no semantic equivalent), and leave a comment explaining why when you do.

Import-order independence

.dark-mode's overrides win regardless of whether you @import "theme.css" before or after @import "tailwindcss" in your own stylesheet. This is worth knowing because Tailwind v4 changes how it emits a @theme block depending on that order: imported after tailwindcss, @theme's output is wrapped in Tailwind's own theme cascade layer as usual; imported before it, the same block is emitted as unlayered :root, :host CSS instead — and unlayered CSS always wins over anything in a named layer, regardless of selector specificity. A .dark-mode override left inside a normal @layer base block would therefore silently lose to the light-mode defaults the moment a consumer's build put theme.css first.

theme.css avoids this by declaring its dark-mode block unlayered too, with a boosted selector:

:root.dark-mode,
.dark-mode {
    /* ...every semantic token's dark-mode value... */
}

Unlayered vs. unlayered is a fair fight on cascade-layer terms, and :root.dark-mode's higher specificity (over the :root, :host selector Tailwind's own @theme output uses) settles it either way theme.css is imported. If you copy .dark-mode overrides of your own into a project that also ships theme.css, keep them unlayered and this selector shape too, or an innocuous @import reorder later can silently break them.

How to switch to dark mode

Whole-app toggle

Wrap your app in the ThemeProvider, which wraps next-themes and maps its light/dark values onto the .light-mode/.dark-mode classes:

// app/layout.tsx
import { ThemeProvider } from "@properui/ui/providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en" suppressHydrationWarning>
            <body>
                <ThemeProvider>{children}</ThemeProvider>
            </body>
        </html>
    );
}

ThemeProvider exposes three states to your users (Light, Dark, and System) and injects a blocking script so there's no flash of the wrong theme on first paint. It's a thin wrapper around next-themes — despite the package name, next-themes has no Next.js dependency; it reads/writes localStorage and toggles a class on <html> with plain DOM APIs, so ThemeProvider works the same way in a Vite, CRA, or any other plain React app (see the Vite integration guide for a worked example).

Every next-themes prop passes through and overrides the defaults below:

PropDefaultWhat it does
defaultTheme"system"Theme used before the user (or system) has picked one. Pass "light" or "dark" to start the app on a fixed theme.
forcedTheme—Pins the app to one theme and disables setTheme. Use for a light-only or dark-only product, or a page that must ignore the stored preference.
enableSystemtrueWhether "system" (follows prefers-color-scheme) is an available theme. Pass false for a light/dark-only toggle.
storageKey"theme"The localStorage key the chosen theme persists under. Change it if your app already uses that key, or two ThemeProviders on the same origin would clobber each other.
attribute"class"Which <html> attribute the resolved theme is written to. Stays "class" unless you also update the .dark-mode selectors in theme.css and the @custom-variant dark in globals.css to match — the three must stay in sync.

Read or change the current theme anywhere with the re-exported hook:

import { useTheme } from "@properui/ui/providers";

const { theme, resolvedTheme, setTheme } = useTheme();

A minimal toggle button:

"use client";
import { Moon01, Sun } from "@properui/icons";
import { ButtonUtility } from "@properui/ui/components/base/buttons/button-utility";
import { useTheme } from "@properui/ui/providers";

export const ThemeToggle = () => {
    const { resolvedTheme, setTheme } = useTheme();
    return (
        <ButtonUtility
            aria-label="Toggle dark mode"
            icon={resolvedTheme === "dark" ? Sun : Moon01}
            onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
        />
    );
};

If you'd rather manage the class yourself (no next-themes), toggle .dark-mode on the root element directly and persist the choice:

const isDark = document.documentElement.classList.toggle("dark-mode");
localStorage.setItem("theme", isDark ? "dark" : "light");

Section-scoped dark mode

Some sections should render permanently dark regardless of the page's theme: a dark footer, a dark call-to-action band. Put .dark-mode directly on that element instead of hardcoding colors:

<section className="dark-mode bg-primary text-primary">
    <Footer />
</section>

Every component inside resolves its dark tokens without any prop drilling, because the dark custom variant matches .dark-mode at any depth in the ancestor chain, not just on <html>.

FAQs