Theme System & Dark Mode¶
Navigation: Home → Features & Components → Theme System
Table of Contents¶
- Introduction
- next-themes Integration
- Theme Toggle Component
- CSS Variables System
- Hydration Handling
- Theme Persistence
- System Theme Detection
- Usage Examples
- See Also
- Next Steps
Introduction¶
The portfolio implements a comprehensive dark mode system using next-themes, providing seamless theme switching with three modes: Light, Dark, and System (automatic). The implementation ensures no flash of unstyled content (FOUC) and persists user preferences across sessions.
Key Features¶
- ✅ Three Theme Modes: Light, Dark, and System (follows OS preference)
- ✅ Persistent Preferences: Saved to localStorage
- ✅ No FOUC: Prevents theme flash on page load
- ✅ Smooth Transitions: Optional transition animations
- ✅ Type-Safe: Full TypeScript support
- ✅ SSR Compatible: Works with Next.js App Router
- ✅ Accessible: Keyboard navigation and screen reader support
next-themes Integration¶
Installation¶
Provider Setup¶
// app/layout.tsx
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
Provider Configuration¶
| Option | Value | Description |
|---|---|---|
attribute |
"class" |
Adds class="dark" to <html> element |
defaultTheme |
"system" |
Default theme on first visit |
enableSystem |
true |
Allow system theme option |
disableTransitionOnChange |
true |
Prevent transition flash on theme change |
How It Works¶
graph TB
User[User Selects Theme] --> Toggle[Theme Toggle Component]
Toggle --> NextThemes[next-themes Hook]
NextThemes --> LocalStorage[Save to localStorage]
NextThemes --> HTMLClass[Update HTML class]
HTMLClass --> Dark{Dark Mode?}
Dark -->|Yes| AddClass[Add 'dark' class]
Dark -->|No| RemoveClass[Remove 'dark' class]
AddClass --> CSSVars[Apply Dark CSS Variables]
RemoveClass --> CSSVars2[Apply Light CSS Variables]
CSSVars --> Render[Components Re-render]
CSSVars2 --> Render
PageLoad[Page Load] --> CheckStorage{Check localStorage}
CheckStorage -->|Found| ApplyStored[Apply Stored Theme]
CheckStorage -->|Not Found| ApplyDefault[Apply Default System]
ApplyStored --> HTMLClass
ApplyDefault --> SystemCheck{Check OS Preference}
SystemCheck --> HTMLClass
style User fill:#e1f5ff
style HTMLClass fill:#d4edda
style Render fill:#fff3cd
Theme Toggle Component¶
Component Implementation¶
```typescript:1:123:components/theme-toggle.tsx "use client";
import { useTheme } from "next-themes"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Moon, Sun, Monitor, Check } from "lucide-react"; import { useEffect, useState } from "react";
export function ThemeToggle() { const { theme, setTheme, themes } = useTheme(); const [mounted, setMounted] = useState(false);
// useEffect only runs on the client, so now we can safely show the UI useEffect(() => { setMounted(true); }, []);
// Render a placeholder button until mounted to prevent hydration mismatch
if (!mounted) {
return (
Change theme
const getThemeIcon = (themeName: string) => {
switch (themeName) {
case "light":
return
const getThemeLabel = (themeName: string) => { switch (themeName) { case "light": return "Light"; case "dark": return "Dark"; case "system": return "System"; default: return themeName.charAt(0).toUpperCase() + themeName.slice(1); } };
const getCurrentIcon = () => {
if (theme === "system") return
return (
Change theme
### Icon Mapping
| Theme | Icon | Description |
| -------- | ---------- | ----------- |
| `light` | ☀️ Sun | Light mode |
| `dark` | 🌙 Moon | Dark mode |
| `system` | 🖥️ Monitor | Follow OS |
### Dropdown Structure
## CSS Variables System
### Theme Definition
```css
/* app/globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
Color Format: HSL¶
Variables use HSL without units for flexibility:
Usage in Components:
Benefits:
- Easy to adjust opacity:
hsl(var(--background) / 0.5) - Consistent color manipulation
- Better theme customization
Hydration Handling¶
The Problem¶
// ❌ Without hydration handling
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return <Button>{theme}</Button>; // Server: "system", Client: "dark" → MISMATCH!
}
Server renders "system" (default), but client immediately loads "dark" from localStorage → Hydration error!
The Solution¶
```typescript:17:22:components/theme-toggle.tsx const [mounted, setMounted] = useState(false);
// useEffect only runs on the client, so now we can safely show the UI useEffect(() => { setMounted(true); }, []);
```typescript:24:54:components/theme-toggle.tsx
// Render a placeholder button until mounted to prevent hydration mismatch
if (!mounted) {
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
className="text-primary-foreground/80 hover:text-primary-foreground"
>
<Monitor className="h-5 w-5" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Change theme</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem className="flex cursor-pointer items-center gap-2">
<Monitor className="h-4 w-4" />
<span className="flex-1">System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
Strategy:
- Server renders placeholder (Monitor icon)
- Client mounts,
useEffectruns, setsmounted = true - Component re-renders with actual theme
- No hydration mismatch!
HTML Attribute¶
suppressHydrationWarning prevents Next.js from warning about the class attribute mismatch (next-themes manages it).
Theme Persistence¶
localStorage Key¶
Storage Flow¶
graph LR
SetTheme[setTheme'dark'] --> UpdateState[Update React State]
UpdateState --> SaveStorage[localStorage.setItem'theme','dark']
SaveStorage --> UpdateHTML[document.documentElement.classList.add'dark']
UpdateHTML --> CSSApply[CSS Variables Applied]
PageLoad[Page Load] --> CheckStorage[Check localStorage]
CheckStorage --> ApplyTheme[Apply Stored Theme]
ApplyTheme --> UpdateHTML
style SetTheme fill:#e1f5ff
style CSSApply fill:#d4edda
System Theme Detection¶
Media Query¶
next-themes uses prefers-color-scheme media query:
Dynamic Updates¶
Listens for OS theme changes:
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (theme === "system") {
// Update theme automatically
}
});
User Experience:
- User selects "System" theme
- OS theme changes from Light to Dark
- Website automatically switches to Dark mode
- No user action required!
Usage Examples¶
In Header¶
import { ThemeToggle } from "@/components/theme-toggle";
export function Header() {
return (
<header>
<nav>{/* Navigation links */}</nav>
<ThemeToggle />
</header>
);
}
Accessing Theme in Components¶
"use client";
import { useTheme } from "next-themes";
export function MyComponent() {
const { theme, setTheme, systemTheme } = useTheme();
return (
<div>
<p>Current theme: {theme}</p>
<p>System theme: {systemTheme}</p>
<button onClick={() => setTheme("dark")}>Dark Mode</button>
</div>
);
}
Conditional Rendering¶
const { resolvedTheme } = useTheme();
return (
<>
{resolvedTheme === "dark" ? (
<DarkModeComponent />
) : (
<LightModeComponent />
)}
</>
);
Note: resolvedTheme returns "light" or "dark" even when theme is "system".
Custom Theme Colors¶
<ThemeProvider
attribute="class"
defaultTheme="system"
themes={["light", "dark", "blue", "purple"]} // Custom themes
>
{children}
</ThemeProvider>
Then add CSS:
.blue {
--primary: 217 91% 60%;
/* ... other variables */
}
.purple {
--primary: 271 91% 65%;
/* ... other variables */
}
See Also¶
- next-themes Documentation - Official docs
- shadcn/ui Theming - Theming guide
- CSS Variables - MDN reference
- Layout Components - Header integration
Next Steps¶
- Add Theme Animations: Smooth color transitions
- More Themes: Add custom color schemes (blue, purple, etc.)
- Theme Preview: Show theme previews before selecting
- Per-Page Themes: Different themes for different sections
- Accessibility: High contrast theme for vision impairment
- Theme Customizer: Let users create custom themes
Last Updated: 2024-02-09
Related Docs: Components | shadcn/ui | Styling