The infrastructure: next-themes setup
You probably already have this, but the details matter. The configuration has to be exact or you get the flash-of-wrong-theme problem:
/* app/layout.tsx */
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}Three critical props: attribute="class" tells it to add a .dark class to the HTML element (matching Tailwind's class strategy). enableSystem respects the user's OS preference. disableTransitionOnChange prevents a visible transition flash when the theme switches on page load.
The suppressHydrationWarning on the html tag is required because next-themes modifies the DOM before React hydrates. Without it, you get a harmless but annoying console warning.
Eliminating the flash of wrong theme
The flash happens because there's a gap between the server render (which doesn't know the user's theme preference) and the client render (which applies the saved theme). next-themes injects a blocking script to minimize this, but it requires the setup above to work correctly.
If you still see a flash after the correct setup, check these:
1. Make sure ThemeProvider wraps the entire body, not just a section of your layout.
2. Make sure you don't have a hardcoded className="light" or className="dark" on the html or body element. next-themes manages this.
3. Make sure your :root variables define the light theme. The .dark class overrides them. If :root has dark values, the flash will show a dark page before switching to light.
Semantic token structure for dark mode
The reason most dark themes look bad: developers invert values without understanding the relationships. Dark mode is not light mode with the numbers flipped. Each token needs intentional dark values.
Backgrounds: layers, not inversion
In light mode, your surface hierarchy goes: white background, white cards, white popovers. In dark mode, you need visible layering. Background is darkest. Cards are slightly lighter. Popovers are slightly lighter still.
.dark {
--background: 240 10% 3.9%; /* darkest layer */
--card: 240 10% 6%; /* slightly elevated */
--popover: 240 10% 8%; /* most elevated */
}This creates depth. Without layering, cards and popovers disappear into the background because everything is the same dark value.
Accent colors: boost saturation
Colors appear less vivid on dark backgrounds. Your light-mode primary at 239 84% 67% will look duller in dark mode even though the value is the same. Bump the saturation by 5-10% and increase lightness slightly:
/* Light mode */
--primary: 239 84% 67%;
/* Dark mode: more saturated, slightly lighter */
.dark {
--primary: 239 90% 71%;
}Borders: the invisible border problem
The single most common dark mode failure. In light mode, borders at 90% lightness are subtle against white. In dark mode, you need borders at around 15-20% lightness to be visible against dark backgrounds. Many developers use the same low-contrast value and wonder why their cards look like they have no borders.
.dark {
/* Too subtle: invisible borders */
--border: 240 5% 10%; /* barely different from bg */
/* Right: visible but not harsh */
--border: 240 5% 18%; /* clear separation */
}Muted foreground: the readability token
--muted-foreground is your secondary text color. Descriptions, timestamps, placeholders. In light mode, it can go fairly dark (45% lightness). In dark mode, it needs to be bright enough to read but dim enough to feel secondary:
/* Light: darker secondary text */
--muted-foreground: 240 4% 46%;
/* Dark: lighter secondary text */
.dark {
--muted-foreground: 240 5% 55%;
}Chart colors in dark mode
Charts need separate dark mode colors. The same palette that looks good on white often looks muddy on dark backgrounds. Increase brightness and saturation for all chart tokens:
:root {
--chart-1: 239 84% 67%;
--chart-2: 173 58% 39%;
--chart-3: 43 74% 66%;
--chart-4: 27 87% 67%;
--chart-5: 322 65% 55%;
}
.dark {
--chart-1: 239 85% 75%;
--chart-2: 173 65% 52%;
--chart-3: 43 80% 72%;
--chart-4: 27 90% 73%;
--chart-5: 322 70% 65%;
}Smooth theme transitions
Once the flash is eliminated, you might want smooth transitions when users toggle the theme manually. Add a transition to your base styles, but exclude it from the initial load:
/* Only transition when the user actively toggles */
html.transitioning * {
transition: background-color 0.2s ease,
border-color 0.2s ease,
color 0.15s ease;
}In your toggle component, add and remove the transitioning class:
function toggleTheme() {
document.documentElement.classList.add('transitioning');
setTheme(theme === 'dark' ? 'light' : 'dark');
setTimeout(() => {
document.documentElement.classList.remove('transitioning');
}, 300);
}This gives smooth transitions on manual toggle but zero transition on page load, which is exactly what users expect.
Respecting system preference
When defaultTheme="system" is set in your ThemeProvider, the app follows the OS preference. But users should be able to override it. The standard pattern is three options: Light, Dark, System.
import { useTheme } from "next-themes";
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}When set to System, the theme updates automatically if the user changes their OS preference (like macOS auto dark mode at sunset). next-themes handles this listener for you.
Testing dark mode properly
A dark mode that "works" and a dark mode that looks good are different things. Test these specifics:
1. Load the page in dark mode with a hard refresh. No flash of light theme.
2. Check contrast ratios. Use your browser's accessibility inspector. Foreground text on background should hit at least 4.5:1 for body text.
3. Verify borders are visible on cards, inputs, and tables in dark mode.
4. Check focus rings. They should be visible in both themes.
5. Verify charts and data visualizations have adequate contrast in dark mode.
6. Test on mobile. Dark mode on OLED screens is visually different from LCD.
Good dark mode requires roughly the same effort as the light theme. It's a parallel design system, not a filter applied on top. For the foundational dark mode theming setup, see shadcn Dark Mode Theming. For deeper color system theory, read Dark Mode Color System. For curated dark palettes to start from, see Dark UI Color Palettes. SeedFlip generates both light and dark themes together, with every token intentionally tuned for each mode. No guesswork. No washed-out dark accents. No invisible borders.