Skip to main content
The Starter Kit includes a complete dark mode implementation using next-themes and Tailwind CSS. Users can toggle between light, dark, and system themes with their preference persisted across sessions.

Overview

The theme system provides:
  • Light and dark mode with instant switching
  • System preference detection that respects OS settings
  • Persistent user selection stored in localStorage
  • No flash of unstyled content on page load
  • Tailwind integration using dark: class modifiers

Architecture

ThemeProvider Setup

The theme system is initialized in the root layout:
app/layout.tsx
import { ThemeProvider } from 'next-themes'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <DeploymentModeProvider deploymentConfig={deploymentConfig}>
          <AuthProvider user={user}>
            <ThemeProvider
              attribute="class"
              defaultTheme="system"
              enableSystem
              disableTransitionOnChange
            >
              {children}
            </ThemeProvider>
          </AuthProvider>
        </DeploymentModeProvider>
      </body>
    </html>
  )
}
attribute
string
default:"class"
How the theme is applied. Using "class" enables Tailwind’s dark: modifiers.
defaultTheme
string
default:"system"
The default theme when no preference is stored. "system" follows the OS preference.
enableSystem
boolean
default:true
Allows detecting and using the system theme preference.
disableTransitionOnChange
boolean
default:false
Prevents CSS transitions during theme switches for instant changes. Set to true to avoid visual glitches.
The suppressHydrationWarning prop on the <html> element prevents hydration warnings caused by next-themes modifying the class attribute before React hydrates.

ThemeSwitcher Component

The header includes a theme toggle button:
components/theme-switcher.tsx
'use client'

import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'

export function ThemeSwitcher() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
    >
      <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      <span className="sr-only">Toggle theme</span>
    </Button>
  )
}
The component returns null until mounted to prevent hydration mismatches, since the theme is determined client-side.

Tailwind Configuration

Enabling Dark Mode

Dark mode is enabled in tailwind.config.ts:
tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  darkMode: "class", // Enable class-based dark mode
  theme: {
    extend: {
      // Your theme customizations
    },
  },
  plugins: [],
};

export default config;
darkMode
string
default:"media"
Set to "class" to use next-themes with Tailwind’s dark: modifier. The alternative "media" uses CSS media queries only.

Dark Mode Color Palette

Define colors that work in both themes:
tailwind.config.ts
const config: Config = {
  theme: {
    extend: {
      colors: {
        // Semantic colors that adapt to theme
        background: {
          DEFAULT: '#ffffff',
          dark: '#0a0a0a',
        },
        foreground: {
          DEFAULT: '#0a0a0a',
          dark: '#ffffff',
        },
        // Component-specific colors
        card: {
          DEFAULT: '#ffffff',
          dark: '#171717',
        },
        'card-foreground': {
          DEFAULT: '#0a0a0a',
          dark: '#fafafa',
        },
        border: {
          DEFAULT: '#e5e5e5',
          dark: '#262626',
        },
        // Brand colors with dark variants
        primary: {
          50: '#f0fdf4',
          100: '#dcfce7',
          200: '#bbf7d0',
          300: '#86efac',
          400: '#4ade80',
          500: '#22c55e',
          600: '#16a34a',
          700: '#15803d',
          800: '#166534',
          900: '#14532d',
          950: '#052e16',
        },
      },
    },
  },
};

Styling Components for Dark Mode

Basic Dark Mode Styles

Use Tailwind’s dark: modifier to apply dark mode styles:
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
  <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
    Title adapts to theme
  </h1>
  <p className="text-gray-600 dark:text-gray-400">
    Secondary text with good contrast in both modes
  </p>
</div>

Component Example

Create theme-aware components:
components/ui/card.tsx
import { cn } from '@/lib/utils'

interface CardProps {
  children: React.ReactNode
  className?: string
}

export function Card({ children, className }: CardProps) {
  return (
    <div
      className={cn(
        'rounded-lg border bg-white p-6 shadow-sm',
        'dark:border-gray-800 dark:bg-gray-900',
        className
      )}
    >
      {children}
    </div>
  )
}

Complex Color Transitions

Handle colors that need different values in each theme:
<button className="
  bg-blue-500 hover:bg-blue-600 
  dark:bg-blue-600 dark:hover:bg-blue-700
  text-white
  border border-blue-600 dark:border-blue-500
">
  Themed Button
</button>

CSS Variables Approach

For dynamic theming, use CSS variables:

Define Variables

app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 0 0% 4%;
    --card: 0 0% 100%;
    --card-foreground: 0 0% 4%;
    --primary: 142 76% 45%;
    --primary-foreground: 0 0% 100%;
    --border: 0 0% 90%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 0 0% 4%;
    --foreground: 0 0% 98%;
    --card: 0 0% 9%;
    --card-foreground: 0 0% 98%;
    --primary: 142 70% 50%;
    --primary-foreground: 0 0% 4%;
    --border: 0 0% 15%;
  }
}

Use in Tailwind

Reference CSS variables in Tailwind config:
tailwind.config.ts
const config: Config = {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        card: {
          DEFAULT: 'hsl(var(--card))',
          foreground: 'hsl(var(--card-foreground))',
        },
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        border: 'hsl(var(--border))',
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
    },
  },
};

Apply in Components

<div className="bg-background text-foreground border-border">
  <div className="bg-card text-card-foreground rounded-lg">
    Theme-aware card
  </div>
</div>
Using HSL color values in CSS variables makes it easy to adjust saturation and lightness programmatically.

Advanced Theme Patterns

Multi-Theme Dropdown

Create a full theme selector with light, dark, and system options:
components/theme-selector.tsx
'use client'

import { Check, Moon, Sun, Monitor } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'

export function ThemeSelector() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }

  const themes = [
    { value: 'light', label: 'Light', icon: Sun },
    { value: 'dark', label: 'Dark', icon: Moon },
    { value: 'system', label: 'System', icon: Monitor },
  ]

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        {themes.map((t) => (
          <DropdownMenuItem
            key={t.value}
            onClick={() => setTheme(t.value)}
            className="cursor-pointer"
          >
            <t.icon className="mr-2 h-4 w-4" />
            <span>{t.label}</span>
            {theme === t.value && <Check className="ml-auto h-4 w-4" />}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Detecting System Theme

Access the resolved theme (accounting for system preference):
'use client'

import { useTheme } from 'next-themes'

export function ThemedComponent() {
  const { theme, resolvedTheme } = useTheme()
  
  // theme: 'light' | 'dark' | 'system'
  // resolvedTheme: 'light' | 'dark' (actual computed theme)
  
  return (
    <div>
      Selected: {theme}
      Active: {resolvedTheme}
    </div>
  )
}

Conditional Rendering

Render different content based on theme:
import { useTheme } from 'next-themes'

export function ThemedImage() {
  const { resolvedTheme } = useTheme()
  
  return (
    <img
      src={resolvedTheme === 'dark' ? '/logo-dark.svg' : '/logo-light.svg'}
      alt="Logo"
    />
  )
}

Custom Color Schemes

Creating Theme Variants

Define multiple color schemes:
app/globals.css
:root {
  --theme-blue-primary: 219 95% 55%;
  --theme-green-primary: 142 76% 45%;
  --theme-purple-primary: 271 81% 56%;
}

[data-color-scheme="blue"] {
  --primary: var(--theme-blue-primary);
}

[data-color-scheme="green"] {
  --primary: var(--theme-green-primary);
}

[data-color-scheme="purple"] {
  --primary: var(--theme-purple-primary);
}
Implement color scheme switching:
'use client'

import { useEffect, useState } from 'react'

export function ColorSchemeSelector() {
  const [scheme, setScheme] = useState('blue')
  
  useEffect(() => {
    document.documentElement.setAttribute('data-color-scheme', scheme)
  }, [scheme])
  
  return (
    <select value={scheme} onChange={(e) => setScheme(e.target.value)}>
      <option value="blue">Blue</option>
      <option value="green">Green</option>
      <option value="purple">Purple</option>
    </select>
  )
}

Best Practices

Use semantic color names like background, foreground, border instead of specific colors like white or gray-900. This makes theme changes easier.
// Good
<div className="bg-background text-foreground">

// Avoid
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
Always test your UI in both light and dark modes. Colors that work in light mode may have insufficient contrast in dark mode.
Ensure WCAG AA contrast ratios (4.5:1 for text) in both themes. Use tools like Contrast Checker.
Set disableTransitionOnChange to prevent CSS transitions from animating during theme switches, which can cause visual glitches.
For logos and images, provide theme-specific variants or use images that work in both themes. Transparent PNGs and SVGs work best.
next-themes automatically persists the user’s theme preference in localStorage. No additional code needed.

Accessibility Considerations

Respecting System Preferences

The default system theme respects the user’s OS-level preference:
<ThemeProvider defaultTheme="system" enableSystem>
This ensures users who have set a dark mode preference at the OS level see dark mode by default.

Reduced Motion

Respect the prefers-reduced-motion setting:
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Focus Indicators

Ensure focus indicators are visible in both themes:
<button className="
  focus:outline-none 
  focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
  dark:focus:ring-primary-400 dark:focus:ring-offset-gray-900
">
  Button with themed focus
</button>

Troubleshooting

If you see a flash when the page loads, ensure suppressHydrationWarning is on the <html> element:
<html lang="en" suppressHydrationWarning>
Ensure next-themes can access localStorage. Check browser settings and privacy modes. The theme is stored in localStorage.theme.
If theme toggle icons don’t switch properly, verify both icons are present and CSS transitions are working:
<Sun className="... dark:scale-0" />
<Moon className="... dark:scale-100" />
If you see hydration errors, ensure theme-dependent components use the mounted check:
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null

Next Steps