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.
SERP Blocks Team
Product
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-themesOr with other package managers:
pnpm add next-themes
yarn add next-themes
bun add next-themesStep 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 (.darkor.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:
Text contrast — Can you read all text comfortably? Pay special attention to muted text, placeholder text, and disabled states.
Borders and dividers — Are they visible but not harsh? Borders that are invisible in dark mode are a common issue.
Focus rings — Do keyboard focus indicators show up clearly in both themes?
Images and icons — Do any disappear or look wrong against the changed background?
Third-party embeds — Do iframes, embedded widgets, or external content clash with your theme?
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:
suppressHydrationWarningis on the<html>tagThe
ThemeProvideris as high in the component tree as possibleNo 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:
81 hero sections adapt to dark mode automatically
132 feature sections respect your theme variables
20 dashboard layouts work in both light and dark
110 card components switch seamlessly
17 navbar designs handle theme-aware navigation
21 footer designs maintain readability in either mode
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:
The
ThemeProviderwraps your entire applicationThe
attribute="class"prop is set correctlyNo CSS-in-JS library is overriding the theme class
Summary
Dark mode in Next.js with Shadcn UI follows a clean, predictable pattern:
Install
next-themesfor theme managementCreate a
ThemeProviderwrapper componentAdd the provider to your root layout with
attribute="class"Set
darkMode: "class"in your Tailwind configDefine light and dark CSS variables in your global stylesheet
Build a toggle component with
useThemeUse semantic color classes (
bg-background,text-foreground) instead of hardcoded colorsTest 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.