Dark Mode Done Right with next-themes
Dark mode is no longer a nice-to-have — it's expected. But implementing it poorly can be worse than not having it at all. Let's look at how to get it right.
The Flash Problem
The biggest challenge with dark mode in server-rendered apps is the dreaded "flash of wrong theme." The page renders with one theme, then JavaScript kicks in and switches to the correct one. It's jarring and unprofessional.
The solution? next-themes handles this elegantly by injecting a tiny script that runs before React hydration.
Setting It Up
The setup is surprisingly straightforward:
// components/ThemeProvider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export default function ThemeProvider({ children }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
>
{children}
</NextThemesProvider>
);
}The attribute="class" tells it to toggle the dark class on <html>, which pairs perfectly with Tailwind's darkMode: "class" configuration.
Designing for Both Themes
The key to good dark mode isn't just inverting colors. You need to consider contrast, readability, and visual hierarchy in both modes independently.
For my portfolio, I use Tailwind's dark: prefix throughout:
<p className="text-stone-600 dark:text-stone-400">
This text has good contrast in both modes.
</p>The Theme Toggle
A common mistake is rendering the toggle before the component mounts, which causes hydration mismatches. Always gate the toggle behind a mounted state:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="w-9 h-9" />;Tips for Great Dark Mode
- Don't use pure black —
stone-950or similar dark greys are easier on the eyes - Reduce contrast slightly in dark mode —
stone-100instead of white for text - Test both modes during development, not just at the end
- Respect system preference as the default, but let users override