UI · Starter

Dark Mode

Flash-free dark mode for Next.js 14+ with Tailwind — three-way light/dark/system toggle, localStorage persistence, OS preference sync, and hydration-safe rendering via next-themes.

Read the Getting Access guideif you haven't yet.

1. Get the file

Sign in at marrowstack.dev, open the Dark Mode block, and click Copy all files. Paste lib/darkmode.ts and the ThemeToggle component into your project.

2. Prerequisites

  • Next.js 14 or 15, App Router
  • Tailwind CSS with darkMode: 'class' set in your config
  • TypeScript 5+

3. Install

  1. Copy the block files into your project.
  2. Install peer dependencies:
bash
npm install next-themes

4. Configure Tailwind

Enable class-based dark mode in your Tailwind config — this is required for dark: variants to work:

tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  darkMode: 'class',   // ← required
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  // ...
}

export default config

5. Wire it in

Wrap your root layout with ThemeProvider. The suppressHydrationWarning on <html> prevents the React hydration mismatch that occurs when the theme is resolved client-side:

app/layout.tsx
import { ThemeProvider } from '@/lib/darkmode'

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>
  )
}
ThemeToggle component
'use client'
import { ThemeToggle } from '@/lib/darkmode'

// Drop anywhere in your nav — cycles through light → dark → system
export function Navbar() {
  return (
    <nav>
      <ThemeToggle />
    </nav>
  )
}
Using the theme hook
'use client'
import { useTheme } from 'next-themes'

export function CustomToggle() {
  const { theme, setTheme, resolvedTheme } = useTheme()

  return (
    <button onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
      {resolvedTheme === 'dark' ? 'Switch to light' : 'Switch to dark'}
    </button>
  )
}

Use Tailwind's dark: prefix to style any element conditionally:

Tailwind dark: usage
<div className="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
  <p className="text-sm dark:text-gray-400">Adapts to the active theme.</p>
</div>

6. Verify it works

  1. Click the ThemeToggle and confirm the page switches between light and dark without a flash.
  2. Refresh the page and confirm the theme persists (stored in localStorage).
  3. Set theme to system and toggle your OS dark mode preference — confirm the page follows.
  4. Open DevTools → Elements and confirm the dark class is added to and removed from <html>.

7. Failure modes & fixes

Flash of wrong theme on page load

Cause: suppressHydrationWarning is missing from the <html> tag, or ThemeProvider is not the outermost wrapper.

Fix: Add suppressHydrationWarning to <html lang="en" suppressHydrationWarning>. Ensure ThemeProvider wraps all children in the root layout.

dark: Tailwind classes have no effect

Cause: darkMode is set to 'media' or not set in tailwind.config.ts.

Fix: Set darkMode: 'class' in tailwind.config.ts. The block uses the class strategy — 'media' ignores the ThemeProvider.

Theme resets to default on every page navigation

Cause: ThemeProvider is mounted inside a Client Component that remounts on navigation.

Fix: Move ThemeProvider to the root app/layout.tsx (Server Component), not inside any client layout or page component.