Dark Mode
Enable and customize dark mode across all Nim UI components
Nim UI has first-class dark mode support built into every component. Dark mode is implemented using the Tailwind CSS dark: variant with a class-based strategy, giving you full control over when and how dark mode is applied.
Enabling Dark Mode
Dark mode is activated by adding the dark class to your <html> element. When this class is present, all Nim UI components automatically switch to their dark color scheme.
<!-- Light mode (default) -->
<html>
<body>
<App />
</body>
</html>
<!-- Dark mode -->
<html class="dark">
<body>
<App />
</body>
</html>Implementation Approaches
Manual Toggle
The simplest approach is a toggle button that adds or removes the dark class.
import { useState, useEffect } from 'react';
import { Button } from '@nim-ui/components';
function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDark]);
return (
<Button
variant="ghost"
onClick={() => setIsDark(!isDark)}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? 'Light Mode' : 'Dark Mode'}
</Button>
);
}System Preference Detection
Detect the user's operating system theme preference using the prefers-color-scheme media query.
import { useState, useEffect } from 'react';
function useSystemTheme() {
const [isDark, setIsDark] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
setIsDark(e.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
useEffect(() => {
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDark]);
return isDark;
}Persistent Theme with localStorage
Save the user's preference so it persists across sessions.
import { useState, useEffect } from 'react';
type Theme = 'light' | 'dark' | 'system';
function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
useEffect(() => {
const root = document.documentElement;
const applyTheme = () => {
if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'light') {
root.classList.remove('dark');
} else {
// System preference
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
root.classList.toggle('dark', prefersDark);
}
};
applyTheme();
localStorage.setItem('theme', theme);
// Listen for system changes when in "system" mode
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme();
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
}, [theme]);
return { theme, setTheme };
}Preventing Flash of Incorrect Theme
Add a script in the <head> to set the theme before the page renders. This prevents a flash of the wrong theme on page load.
<head>
<script>
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' ||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>Component Dark Mode Support
All Nim UI components automatically adapt to dark mode. Here is how the key color mappings work:
Backgrounds
| Element | Light | Dark |
|---|---|---|
| Page background | bg-white | dark:bg-neutral-950 |
| Card background | bg-white | dark:bg-neutral-900 |
| Input background | bg-white | dark:bg-neutral-800 |
| Hover background | bg-neutral-50 | dark:bg-neutral-800 |
Text
| Element | Light | Dark |
|---|---|---|
| Primary text | text-neutral-900 | dark:text-neutral-100 |
| Secondary text | text-neutral-600 | dark:text-neutral-400 |
| Muted text | text-neutral-400 | dark:text-neutral-500 |
| Link text | text-primary-600 | dark:text-primary-400 |
Borders
| Element | Light | Dark |
|---|---|---|
| Default border | border-neutral-200 | dark:border-neutral-800 |
| Input border | border-neutral-300 | dark:border-neutral-700 |
| Divider | divide-neutral-200 | dark:divide-neutral-800 |
Using Dark Mode in Custom Components
When building your own components alongside Nim UI, follow the same pattern of adding dark: variants for each color utility.
Custom Card
This card adapts its colors based on the current theme mode.
Code
<div className="rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
Custom Card
</h3>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">This card adapts its colors based on the current theme mode.</p>
<div className="flex gap-2">
<span className="px-2 py-1 text-xs rounded bg-primary-100 dark:bg-primary-900 text-primary-700 dark:text-primary-300">
Tag
</span>
</div>
</div>Theme Context Provider
For larger applications, create a React context to provide theme state to all components.
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const root = document.documentElement;
let isDark: boolean;
if (theme === 'system') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else {
isDark = theme === 'dark';
}
root.classList.toggle('dark', isDark);
setResolvedTheme(isDark ? 'dark' : 'light');
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}Usage:
import { ThemeProvider, useTheme } from './ThemeProvider';
import { Button } from '@nim-ui/components';
function App() {
return (
<ThemeProvider>
<Navigation />
<MainContent />
</ThemeProvider>
);
}
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<div className="flex gap-2">
<Button
variant={theme === 'light' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setTheme('light')}
>
Light
</Button>
<Button
variant={theme === 'dark' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setTheme('dark')}
>
Dark
</Button>
<Button
variant={theme === 'system' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setTheme('system')}
>
System
</Button>
</div>
);
}How Dark Mode Is Wired
Tailwind v4 has no darkMode setting and no content array — a tailwind.config.js isn't read at all unless you point at it explicitly with @config, and one sitting unused does nothing (Configuration covers this in full). The dark: variant this page has been toggling is bound by one declaration, already compiled into the stylesheet you import:
@custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *));This line is declared in packages/ui/src/styles.css and ships inside @import '@nim-ui/components/styles' — importing that one file, as shown throughout this page, is the entire setup. It matches more than the .dark class: an element under a [data-theme="dark"] attribute activates dark: utilities too, a hook for frameworks that set a data-theme attribute instead of a class (this documentation site's own Fumadocs toggle only ever sets .dark).
The binding also isn't something your own stylesheet can change. @import '@nim-ui/components/styles' brings in an already-compiled bundle in which @custom-variant has already been expanded into concrete :where(...) selectors on every rule, so redeclaring @custom-variant dark yourself — before or after that import — has no effect, and there is no darkMode setting to flip instead. See Theming → Dark mode for the full mechanism, including why a single OKLCH value per shade covers both themes.
Best Practices
- Always provide
dark:variants. Every color utility in your custom components should have a correspondingdark:variant to ensure a seamless experience. - Test both modes. Verify that text contrast, borders, and backgrounds look correct in both light and dark modes.
- Use semantic color names. Refer to
neutral-100,primary-500, etc. rather than specific hex codes. This makes theme adaptation automatic. - Prevent flash of incorrect theme. Use the inline
<script>technique to apply the theme before React hydrates. - Offer a system preference option. Respect the user's OS-level setting as the default, with the option to override.
- Test images and media. Ensure logos, illustrations, and other media look appropriate in both modes. Consider providing separate assets for light and dark themes.
What's Next?
- Colors - Color palette and design tokens
- Typography - Fonts, sizes, and text styles
- Theming - Advanced theme customization
- Customization - Overriding component styles