← Back to articles

How to Implement Dark Mode in Your App (2026)

Dark mode is expected in 2026. Here's how to implement it properly — with system preference detection, manual toggle, and persistence.

The Simple Way: Tailwind CSS + next-themes

npm install next-themes

Provider Setup

// 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>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Theme Toggle

'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

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

  useEffect(() => setMounted(true), [])
  if (!mounted) return null // Avoid hydration mismatch

  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      {theme === 'dark' ? '☀️' : '🌙'}
    </button>
  )
}

Use Dark Mode Classes

<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
  <h1 className="text-gray-900 dark:text-gray-100">Hello</h1>
  <p className="text-gray-600 dark:text-gray-400">Content</p>
  <button className="bg-blue-600 dark:bg-blue-500 text-white">Click</button>
</div>

That's it. System preference detection, manual toggle, and persistence — handled by next-themes.

The CSS Variables Way (Framework-Agnostic)

Define Variables

/* globals.css */
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --text-secondary: #666666;
  --border: #e5e5e5;
  --accent: #3b82f6;
  --card: #f9fafb;
}

[data-theme="dark"] {
  --bg: #0a0a0a;
  --text: #fafafa;
  --text-secondary: #a3a3a3;
  --border: #262626;
  --accent: #60a5fa;
  --card: #171717;
}

body {
  background: var(--bg);
  color: var(--text);
}

Detect System Preference

// Check system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches

// Listen for changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  document.documentElement.dataset.theme = e.matches ? 'dark' : 'light'
})

Persist Choice

// Save preference
function setTheme(theme) {
  document.documentElement.dataset.theme = theme
  localStorage.setItem('theme', theme)
}

// Load on page load (add to <head> to avoid flash)
const saved = localStorage.getItem('theme')
const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
document.documentElement.dataset.theme = saved || system

Preventing Flash of Wrong Theme

The biggest dark mode bug: white flash on page load. Fix with an inline script in <head>:

<head>
  <script>
    (function() {
      const saved = localStorage.getItem('theme')
      const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
      document.documentElement.classList.add(saved || system)
    })()
  </script>
</head>

This runs before the page renders, preventing any flash. next-themes handles this automatically.

shadcn/ui Dark Mode

shadcn/ui uses CSS variables that automatically support dark mode:

/* Already in shadcn's globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
  }
}

All shadcn components automatically use these variables.

Design Tips for Dark Mode

  1. Don't just invert colors. Pure white on pure black (#fff on #000) is harsh. Use off-white on dark gray (#fafafa on #0a0a0a).

  2. Reduce contrast slightly. Text in dark mode should be ~87% opacity, not 100%.

  3. Adjust shadows. Shadows don't work on dark backgrounds. Use subtle borders instead.

  4. Test images. Some images look bad on dark backgrounds. Add rounded corners and subtle borders.

  5. Adjust brand colors. Some brand colors need lightening for dark mode accessibility.

FAQ

Should every app have dark mode?

In 2026, yes. Users expect it. next-themes makes it trivial to implement.

CSS variables vs Tailwind dark: classes?

Both work. Tailwind dark: is more explicit and co-located with components. CSS variables are cleaner for large design systems.

What about dark mode in emails?

Email clients apply their own dark mode. Use transparent backgrounds and high-contrast text. Test in Gmail, Apple Mail, and Outlook dark modes.

Bottom Line

Use next-themes + Tailwind for Next.js (5 minutes to implement). Use CSS variables for framework-agnostic apps. Key: prevent the flash of wrong theme with an inline <head> script, persist user choice in localStorage, and respect system preference by default.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.