Skip to main content
Next.js Tutorial

Build Light & Dark Theme in Next.js 16 with shadcn/ui

Create an accessible light, dark, and system theme selector with persistent preferences, Tailwind CSS v4, next-themes, and shadcn/ui.

Light and dark versions of the same modern web application dashboard

Dark mode is now an expected preference in many web applications. A good implementation does more than invert a few colors: it respects the operating system, remembers the user's selection, applies the correct theme before the page becomes visible, and gives keyboard and screen-reader users a clear control.

This guide builds that complete theme system with Next.js 16, TypeScript, Tailwind CSS v4, next-themes, and shadcn/ui. You will create a reusable provider and a menu that lets users choose Light, Dark, or System.

What you will build

  • Light and dark color modes
  • Automatic operating-system theme detection
  • A preference saved across visits and synchronized across tabs
  • An accessible theme menu built with shadcn/ui
  • Hydration-safe rendering without an incorrect-theme flash in production

Prerequisites and project structure

Start with a Next.js 16 App Router project using TypeScript and Tailwind CSS v4. Use Node.js 20.9 or newer for Next.js 16. If shadcn/ui is not configured yet, initialize it before adding the components used by the toggle.

Project structure
app/
  layout.tsx
  page.tsx
components/
  ui/
  theme-provider.tsx
  theme-toggle.tsx
public/

1. Install next-themes and shadcn/ui components

Terminal
npm install next-themes
npx shadcn@latest init
npx shadcn@latest add button dropdown-menu

The button component becomes the menu trigger. The dropdown-menu component exposes all three theme choices without turning one icon into an ambiguous one-way action.

2. Create the ThemeProvider

The provider is a Client Component because it coordinates browser-only state. Passing through the library's component props keeps the wrapper reusable and type-safe.

components/theme-provider.tsx
"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return (
    <NextThemesProvider {...props}>
      {children}
    </NextThemesProvider>
  )
}

3. Wrap the root layout

Add the provider close to the root so every route can read and update the theme. The class attribute matches Tailwind's dark-mode selector. System is the default, and enableSystem keeps it responsive to operating-system changes.

app/layout.tsx
import type { Metadata } from "next"
import { ThemeProvider } from "@/components/theme-provider"
import "./globals.css"

export const metadata: Metadata = {
  title: "Theme Demo",
  description: "Light and dark theme example",
}

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}
Why suppressHydrationWarning? next-themes updates the html element after reading the browser preference. The prop suppresses the expected one-level warning on that element; it should not be used to hide unrelated hydration bugs.

4. Build an accessible ThemeToggle

The server cannot know which value is stored in the visitor's browser. Render a stable disabled placeholder until mount, then show the live menu. This avoids markup that depends on an unavailable server-side theme value.

components/theme-toggle.tsx
"use client"

import * as React from "react"
import { Laptop, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

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

  React.useEffect(() => setMounted(true), [])

  if (!mounted) {
    return (
      <Button variant="outline" size="icon" disabled
        aria-label="Choose color theme" />
    )
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon"
          aria-label={`Choose color theme. Current: ${theme}`}>
          <Sun className="h-5 w-5 scale-100 dark:scale-0" />
          <Moon className="absolute h-5 w-5 scale-0 dark:scale-100" />
          <span className="sr-only">Choose color theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          <Sun className="mr-2 h-4 w-4" /> Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          <Moon className="mr-2 h-4 w-4" /> Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          <Laptop className="mr-2 h-4 w-4" /> System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

The visible sun and moon communicate the resolved visual state, while the menu retains the actual choice—including System. The text alternative labels the action rather than merely naming the icon.

components/navbar.tsx
import Link from "next/link"
import { ThemeToggle } from "@/components/theme-toggle"

export function Navbar() {
  return (
    <header className="flex items-center justify-between border-b p-4">
      <Link href="/" className="font-semibold">My App</Link>
      <ThemeToggle />
    </header>
  )
}

6. Use theme-aware Tailwind classes

shadcn/ui uses semantic CSS variables such as background and foreground. Prefer those tokens so the whole interface follows the selected theme. Tailwind's dark: variant is useful when one element needs an explicit difference.

app/page.tsx
export default function Home() {
  return (
    <main className="min-h-screen bg-background text-foreground">
      <section className="mx-auto max-w-3xl px-6 py-24">
        <h1 className="text-4xl font-bold">Theme-ready interface</h1>
        <p className="mt-4 text-muted-foreground">
          This content follows light, dark, or system mode.
        </p>
        <div className="mt-8 rounded-xl border bg-card p-6
          shadow-sm dark:shadow-none">
          Semantic colors keep components consistent.
        </div>
      </section>
    </main>
  )
}

How the theme system works

  1. The ThemeProvider injects an early script and manages the class on html.
  2. It checks the saved theme value in local storage.
  3. If the choice is System, it resolves prefers-color-scheme from the operating system.
  4. Tailwind and shadcn/ui respond to the resulting light or dark class and variables.
  5. Calling setTheme updates the page, saves the preference, and synchronizes other open tabs.

Common mistakes

ProblemSolution
ThemeProvider is missing "use client"Keep the directive at the top of the provider file.
Only setTheme("dark") is offeredProvide Light, Dark, and System choices so the control is reversible.
Hydration mismatch appears in the toggleDelay theme-dependent UI until mount and add suppressHydrationWarning to html.
Dark styles never appearPass attribute="class" and confirm the Tailwind/shadcn theme setup is present.
Icons have no accessible nameAdd an action-oriented aria-label and screen-reader text.

Best practices, performance, and accessibility

  • Keep the ThemeProvider at the root and use one shared ThemeToggle.
  • Use semantic theme tokens rather than repeating hard-coded light and dark colors.
  • Test all three choices, refresh the page, and test across two browser tabs.
  • Keep visible focus styles and verify the menu by keyboard.
  • Check text, icons, borders, charts, syntax highlighting, form controls, and hover states for sufficient contrast in both modes.
  • Respect reduced-motion preferences if you add custom theme animations.
  • Do not read local storage during server rendering. Keep the theme interaction client-side and small.

Frequently asked questions

Does dark mode improve SEO?

Not directly. Dark mode primarily improves user choice and comfort. Search visibility still depends on useful content, crawlability, performance, and other technical signals.

Does next-themes remember the selected theme?

Yes. next-themes stores the selected theme in local storage and applies it again on future visits.

Can the website follow the operating system theme?

Yes. Set defaultTheme to system and enableSystem on ThemeProvider, then offer System as one of the theme menu choices.

Is shadcn/ui required for dark mode?

No. next-themes works independently of a component library. shadcn/ui provides accessible building blocks and theme-ready design tokens.

Why does the toggle wait until the component mounts?

The server cannot read local storage or the browser theme preference. Waiting until mount prevents server and client markup from disagreeing during hydration.

Official references

Final thoughts

A production-ready theme is a small system, not a single moon button. The provider applies and persists the preference, the root layout creates a hydration-safe boundary, semantic tokens carry the design across components, and an accessible menu leaves Light, Dark, and System under the user's control.

Once this foundation works, the next useful exercise is a responsive navigation bar that keeps the toggle reachable on both desktop and mobile screens. For more application fundamentals, continue with the Next.js 16 beginner guide or the App Router guide.

Need help with a Next.js interface?

NavTech Solution builds responsive, accessible, and maintainable React and Next.js experiences.

Discuss your project