Back to blog
Dev Education
12 min read
Shadcn UI
Dark Mode

Dark Mode in Next.js with Shadcn UI: Complete Guide

Step-by-step tutorial for adding dark mode to a Next.js project using Shadcn UI and Tailwind CSS. Covers next-themes setup, CSS variable switching, theme toggle components, and testing strategies.

SB

SERP Blocks Team

Product

Dark Mode in Next.js with Shadcn UI: Complete Guide cover

Dark mode is no longer a nice-to-have. Users expect it. Operating systems default to it. Developers building data-dense interfaces — dashboards, editors, analytics tools — often prefer it themselves. If your Next.js application does not support dark mode, you are creating friction for a significant portion of your audience.

The good news is that the combination of Next.js, Shadcn UI, and Tailwind CSS makes dark mode implementation straightforward. Shadcn UI's theming system is built on CSS variables that swap between light and dark values, Tailwind provides the dark: variant for custom styles, and the next-themes library handles the plumbing of theme detection, persistence, and hydration.

This guide walks through the complete setup from scratch: installing dependencies, configuring the theme provider, setting up CSS variables, building a toggle component, and testing your implementation. If you already have a Shadcn UI project running, you can skip ahead to the sections you need.

How Dark Mode Works in Shadcn UI

Before writing any code, it helps to understand the architecture.

Shadcn UI does not use JavaScript-based theming like styled-components or emotion. Instead, it defines a set of CSS variables in your global stylesheet — one set under :root for light mode, and another set under .dark for dark mode. Every Shadcn component references these variables through Tailwind utility classes.

When the theme switches, the .dark class is added to or removed from the <html> element. The CSS variables resolve to their dark values, and every component updates instantly — no re-render, no prop changes, no React context consumers firing. It is pure CSS, which means it is fast and avoids the flash-of-incorrect-theme problem that JavaScript-based approaches suffer from.

Here is a simplified view of the variable structure:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 240 10% 3.9%;
    --primary: 240 5.9% 10%;
    --primary-foreground: 0 0% 98%;
    --muted: 240 4.8% 95.9%;
    --muted-foreground: 240 3.8% 46.1%;
    --border: 240 5.9% 90%;
    --ring: 240 5.9% 10%;
  }

  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    --card: 240 10% 3.9%;
    --card-foreground: 0 0% 98%;
    --primary: 0 0% 98%;
    --primary-foreground: 240 5.9% 10%;
    --muted: 240 3.7% 15.9%;
    --muted-foreground: 240 5% 64.9%;
    --border: 240 3.7% 15.9%;
    --ring: 240 4.9% 83.9%;
  }
}

Notice the HSL format without the hsl() wrapper. Shadcn UI uses this convention so Tailwind can apply opacity modifiers. The variables are consumed in tailwind.config.ts like this:

theme: {
  extend: {
    colors: {
      background: "hsl(var(--background))",
      foreground: "hsl(var(--foreground))",
      primary: {
        DEFAULT: "hsl(var(--primary))",
        foreground: "hsl(var(--primary-foreground))",
      },
      // ... other color mappings
    },
  },
},

This two-layer system — CSS variables for values, Tailwind for consumption — gives you the best of both worlds: instant theme switching via CSS, and utility-class ergonomics in your JSX.

Step 1: Install next-themes

The next-themes library by Paco Coursey handles theme detection (system preference), persistence (localStorage), and hydration (avoiding flash of wrong theme on initial load). It is the standard choice for Next.js dark mode and the one Shadcn UI recommends.

npm install next-themes

Or with other package managers:

pnpm add next-themes
yarn add next-themes
bun add next-themes

Step 2: Create the Theme Provider

Create a theme provider component that wraps next-themes and can be used in your root layout. This component must be a client component because it uses React context internally.

Create a file at src/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>;
}

This is a thin wrapper. The reason for the wrapper rather than using NextThemesProvider directly is that you may want to add additional providers or logic later (analytics tracking on theme change, for example) without modifying your root layout.

Step 3: Add the Provider to Your Root Layout

Open your root layout file (typically src/app/layout.tsx) and wrap the page content with the ThemeProvider:

import { ThemeProvider } from "@/components/theme-provider";

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>
  );
}

Key props to understand:

  • attribute="class" — This tells next-themes to apply the theme by adding a class (.dark or .light) to the <html> element. This is required for Shadcn UI's CSS variable approach to work.

  • defaultTheme="system" — Respects the user's OS preference on first visit.

  • enableSystem — Enables automatic detection of system dark/light preference.

  • disableTransitionOnChange — Prevents a brief CSS transition flash when the theme switches. Optional, but recommended for a cleaner feel.

  • suppressHydrationWarning — Added to <html> to prevent React from warning about the class attribute mismatch between server and client render. This is expected behavior with next-themes.

Step 4: Configure Tailwind for Dark Mode

In your tailwind.config.ts, ensure the dark mode strategy is set to class:

import type { Config } from "tailwindcss";

const config: Config = {
  darkMode: "class",
  content: [
    "./src/**/*.{ts,tsx}",
  ],
  theme: {
    extend: {
      // your theme extensions
    },
  },
  plugins: [],
};

export default config;

The darkMode: "class" setting tells Tailwind to activate dark: variants when a .dark class is present on a parent element, rather than using the prefers-color-scheme media query. This is essential because next-themes controls the class, not the media query.

If you initialized your project with npx shadcn@latest init, this is likely already configured correctly.

Step 5: Set Up CSS Variables

If you used shadcn init, your globals.css already contains the full variable set. If not, or if you want to customize the palette, here is the complete default set:

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 240 10% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 240 10% 3.9%;
    --primary: 240 5.9% 10%;
    --primary-foreground: 0 0% 98%;
    --secondary: 240 4.8% 95.9%;
    --secondary-foreground: 240 5.9% 10%;
    --muted: 240 4.8% 95.9%;
    --muted-foreground: 240 3.8% 46.1%;
    --accent: 240 4.8% 95.9%;
    --accent-foreground: 240 5.9% 10%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 0 0% 98%;
    --border: 240 5.9% 90%;
    --input: 240 5.9% 90%;
    --ring: 240 5.9% 10%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    --card: 240 10% 3.9%;
    --card-foreground: 0 0% 98%;
    --popover: 240 10% 3.9%;
    --popover-foreground: 0 0% 98%;
    --primary: 0 0% 98%;
    --primary-foreground: 240 5.9% 10%;
    --secondary: 240 3.7% 15.9%;
    --secondary-foreground: 0 0% 98%;
    --muted: 240 3.7% 15.9%;
    --muted-foreground: 240 5% 64.9%;
    --accent: 240 3.7% 15.9%;
    --accent-foreground: 0 0% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 0 0% 98%;
    --border: 240 3.7% 15.9%;
    --input: 240 3.7% 15.9%;
    --ring: 240 4.9% 83.9%;
  }
}

Every variable has a light value (under :root) and a dark value (under .dark). When you use Tailwind classes like bg-background, text-foreground, or border-border, they resolve to these CSS variables — and automatically switch when the theme changes.

Customizing the palette

To create a custom dark mode palette, adjust the HSL values under .dark. For example, if you want a warmer dark background instead of the cool gray default:

.dark {
  --background: 220 15% 6%;     /* slightly warm dark blue */
  --foreground: 220 10% 94%;    /* warm off-white */
  --card: 220 15% 8%;           /* slightly lighter than background */
  --card-foreground: 220 10% 94%;
  --muted: 220 12% 14%;
  --muted-foreground: 220 10% 58%;
  --border: 220 12% 16%;
}

The key principle is maintaining sufficient contrast ratios. The WCAG AA standard requires a 4.5:1 contrast ratio for normal text and 3:1 for large text. Tools like the WebAIM Contrast Checker can verify your custom values meet accessibility requirements.

Step 6: Build a Theme Toggle Component

Users need a way to switch themes. Here is a toggle component using the Shadcn UI Button and DropdownMenu components:

"use client";

import * as React from "react";
import { 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 { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

This component shows a sun icon in light mode and a moon icon in dark mode, with a smooth CSS transition between them. The dropdown gives users three choices: light, dark, or system (follow OS preference).

For a simpler toggle without the dropdown, you can use a single button that cycles through themes:

"use client";

import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
    >
      <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>
  );
}

Place the toggle in your site header or navigation bar. It is a common pattern to put it in the top-right corner of the navbar, next to user account controls.

Step 7: Using dark: Variants in Custom Components

When you build custom components beyond what Shadcn UI provides, use Tailwind's dark: variant to define dark mode styles:

<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
  <h3 className="text-zinc-900 dark:text-zinc-100 text-lg font-semibold">
    Custom Card
  </h3>
  <p className="text-zinc-600 dark:text-zinc-400 mt-2">
    This card respects the current theme.
  </p>
</div>

However, the better approach is to use Shadcn UI's semantic color classes whenever possible:

<div className="bg-card text-card-foreground border rounded-lg p-6">
  <h3 className="text-lg font-semibold">Custom Card</h3>
  <p className="text-muted-foreground mt-2">
    This card automatically adapts to the current theme.
  </p>
</div>

The second version is shorter, more maintainable, and automatically respects any custom theme you define. The bg-card, text-card-foreground, border, and text-muted-foreground classes all resolve to CSS variables that switch with the theme. You do not need explicit dark: variants because the variable system handles it.

Reserve dark: variants for cases where the semantic classes do not cover your need — for example, custom gradients, specific opacity values, or third-party component overrides.

Step 8: Handling Images and Media in Dark Mode

Some visual elements need different treatment in dark mode. Logos with dark text on a transparent background become invisible on a dark background. Charts and diagrams may need inverted color schemes.

For images, you can conditionally render different sources:

"use client";

import { useTheme } from "next-themes";
import Image from "next/image";

export function Logo() {
  const { resolvedTheme } = useTheme();

  return (
    <Image
      src={resolvedTheme === "dark" ? "/logo-dark.svg" : "/logo-light.svg"}
      alt="Logo"
      width={120}
      height={32}
    />
  );
}

Note the use of resolvedTheme instead of theme. When the theme is set to "system", theme returns "system" while resolvedTheme returns the actual resolved value ("dark" or "light").

For SVG icons and illustrations, a CSS-based approach often works better:

.dark img[src$=".svg"] {
  filter: invert(1) hue-rotate(180deg);
}

This inverts the colors of SVG images in dark mode. It is a blunt tool — it works well for simple line icons but can produce strange results on complex illustrations. Use it selectively.

Step 9: Testing Dark Mode

Manual testing checklist

Go through every page of your application in both light and dark mode and check:

  1. Text contrast — Can you read all text comfortably? Pay special attention to muted text, placeholder text, and disabled states.

  2. Borders and dividers — Are they visible but not harsh? Borders that are invisible in dark mode are a common issue.

  3. Focus rings — Do keyboard focus indicators show up clearly in both themes?

  4. Images and icons — Do any disappear or look wrong against the changed background?

  5. Third-party embeds — Do iframes, embedded widgets, or external content clash with your theme?

  6. Form controls — Are inputs, selects, and textareas styled correctly in both modes?

System preference detection

Test the "system" theme option by changing your OS dark mode setting and verifying the site responds. On macOS, toggle in System Preferences > Appearance. On Windows, toggle in Settings > Personalization > Colors. The site should switch within a second of the OS change.

Hydration testing

Load the page in dark mode, view the page source (or disable JavaScript), and check for flash of white background. If you see a flash, ensure:

  • suppressHydrationWarning is on the <html> tag

  • The ThemeProvider is as high in the component tree as possible

  • No components are conditionally rendering based on theme during the initial server render

Dark Mode in Pre-Built Block Libraries

One of the significant advantages of using a block library built on Shadcn UI is that dark mode support is inherited automatically. Every block that uses bg-background, text-foreground, bg-card, border, and other semantic classes will switch themes without any modification.

All 1,200+ blocks in SERP Blocks are built with this CSS variable approach. That means:

When you customize the CSS variables as described in Step 5, every block in your project updates to match your custom palette. There is no per-component theming work. You define your colors once, and the entire interface follows.

This is a major time saver compared to approaches where each component has its own color props or style overrides. With CSS variable theming, a single change to --background in the .dark block updates every background across every component on every page.

Common Pitfalls and How to Avoid Them

Hardcoded colors

The most common dark mode bug is a hardcoded color that does not switch. A bg-white that should be bg-background, a text-gray-900 that should be text-foreground. Search your codebase for hardcoded Tailwind color classes and replace them with semantic alternatives.

# Find potential hardcoded colors
grep -r "bg-white\|bg-gray-\|bg-zinc-\|bg-slate-\|text-gray-\|text-zinc-\|text-slate-\|text-black\|text-white" src/

Not every instance needs to change — some components intentionally use fixed colors (a brand logo background, a status indicator). But most layout-level colors should use the variable system.

Z-index and overlay backgrounds

Modal overlays, dropdown menus, and tooltips often have semi-transparent backgrounds. Ensure these work in both themes:

{/* Avoid this */}
<div className="fixed inset-0 bg-black/50" />

{/* Prefer this */}
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm" />

The second version uses the theme-aware bg-background with opacity, which adapts to both light and dark mode. Shadcn UI's Dialog and Sheet components already handle this correctly.

Third-party charts and widgets

Chart libraries like Recharts or Chart.js use their own color systems. You need to pass theme-aware colors to their configuration:

"use client";

import { useTheme } from "next-themes";

export function ChartWrapper() {
  const { resolvedTheme } = useTheme();
  const gridColor = resolvedTheme === "dark" ? "#27272a" : "#e4e4e7";
  const textColor = resolvedTheme === "dark" ? "#a1a1aa" : "#71717a";

  return (
    <ResponsiveContainer>
      <LineChart data={data}>
        <CartesianGrid stroke={gridColor} />
        <XAxis tick={{ fill: textColor }} />
        <YAxis tick={{ fill: textColor }} />
        <Line type="monotone" dataKey="value" stroke="hsl(var(--primary))" />
      </LineChart>
    </ResponsiveContainer>
  );
}

Flash of unstyled content on deployment

In production, if the theme is stored in localStorage but the server renders the default theme, users see a brief flash. The next-themes library handles this with a script injection that runs before React hydrates, but you need to ensure:

  1. The ThemeProvider wraps your entire application

  2. The attribute="class" prop is set correctly

  3. No CSS-in-JS library is overriding the theme class

Summary

Dark mode in Next.js with Shadcn UI follows a clean, predictable pattern:

  1. Install next-themes for theme management

  2. Create a ThemeProvider wrapper component

  3. Add the provider to your root layout with attribute="class"

  4. Set darkMode: "class" in your Tailwind config

  5. Define light and dark CSS variables in your global stylesheet

  6. Build a toggle component with useTheme

  7. Use semantic color classes (bg-background, text-foreground) instead of hardcoded colors

  8. Test both themes across all pages

The CSS variable approach means that any component built on Shadcn UI — whether you write it yourself or use pre-built blocks from a library like SERP Blocks — automatically supports dark mode. You define your palette once, and every component in your application follows.

If you are starting a new project and want dark mode support from day one, the 60 free blocks at SERP Blocks are a good way to test this workflow. Drop a block into your project, toggle the theme, and see it adapt instantly to your CSS variables.