Skip to content

Dark Mode

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

ElementLightDark
Page backgroundbg-whitedark:bg-neutral-950
Card backgroundbg-whitedark:bg-neutral-900
Input backgroundbg-whitedark:bg-neutral-800
Hover backgroundbg-neutral-50dark:bg-neutral-800

Text

ElementLightDark
Primary texttext-neutral-900dark:text-neutral-100
Secondary texttext-neutral-600dark:text-neutral-400
Muted texttext-neutral-400dark:text-neutral-500
Link texttext-primary-600dark:text-primary-400

Borders

ElementLightDark
Default borderborder-neutral-200dark:border-neutral-800
Input borderborder-neutral-300dark:border-neutral-700
Dividerdivide-neutral-200dark: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 Component with Dark Mode

Custom Card

This card adapts its colors based on the current theme mode.

Tag

Label

View 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>
);
}

Tailwind Configuration

Ensure your tailwind.config.js uses the class-based dark mode strategy:

tailwind.config.js
export default {
darkMode: 'class',
content: [
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@nim-ui/components/dist/**/*.js',
],
theme: {
extend: {},
},
};

Best Practices

  1. Always provide dark: variants. Every color utility in your custom components should have a corresponding dark: variant to ensure a seamless experience.
  2. Test both modes. Verify that text contrast, borders, and backgrounds look correct in both light and dark modes.
  3. Use semantic color names. Refer to neutral-100, primary-500, etc. rather than specific hex codes. This makes theme adaptation automatic.
  4. Prevent flash of incorrect theme. Use the inline <script> technique to apply the theme before React hydrates.
  5. Offer a system preference option. Respect the user’s OS-level setting as the default, with the option to override.
  6. 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?