seedflip
Archive
Mixtapes
Pricing
Sign in

shadcn/ui Dark Mode Theming: Complete Guide

Dark mode in shadcn/ui works through CSS variable swaps on a .dark class. Your globals.css defines two sets of HSL values: one under :root for light mode, one under .dark for dark mode. When the class toggles, every component updates automatically. No prop changes. No conditional rendering. Just variables.

Get a dark theme seed with one flip →

How shadcn dark mode actually works

Dark mode in shadcn isn't a separate theme file or a different component library. It's the same components reading the same CSS variable names, but those variables resolve to different values when the .dark class is present on the <html> element.

A button that uses bg-primary text-primary-foreground looks correct in both modes because --primary and --primary-foreground have appropriate values in each context. You define the mapping once and forget about it.

The two variable blocks

Open your globals.css. You'll see two blocks inside @layer base:

@layer base { :root { /* Light mode values */ --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%; } .dark { /* Dark mode values */ --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%; } }

Notice the pattern: what was light becomes dark, and what was dark becomes light. Background flips from white to near-black. Foreground flips from near-black to near-white. Borders go from light gray to dark gray. The variable names don't change. Only the HSL values do.

Setting up the toggle with next-themes

shadcn's recommended approach uses next-themes for Next.js projects. The library handles class toggling, system preference detection, and persistence.

Step 1: Install and wrap your layout

npm install next-themes

In your root layout, wrap the children with ThemeProvider:

import { ThemeProvider } from "next-themes" export default function RootLayout({ children }) { return ( <html lang="en" suppressHydrationWarning> <body> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} </ThemeProvider> </body> </html> ) }

The attribute="class" part is critical. That's what tells next-themes to add a .dark class to the <html> element, which is what your CSS variables are keyed to. The suppressHydrationWarning prevents React from complaining about the class being set before hydration.

Step 2: Add the toggle component

shadcn provides a mode toggle component. You can install it with the CLI or build your own. The core logic is simple:

"use client" import { useTheme } from "next-themes" import { Button } from "@/components/ui/button" import { Moon, Sun } from "lucide-react" export function ModeToggle() { 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" /> </Button> ) }

Click the button, the .dark class toggles, the CSS variables swap, and every shadcn component repaints with the dark values. That's the entire mechanism.

The variables that matter most in dark mode

Not all variables carry equal weight in the dark theme. These are the ones that make or break it:

--background: Your page color. Don't go pure black (0 0% 0%). It looks harsh and creates too much contrast with text. Something between 4-10% lightness with a slight hue tint reads better. The default 240 10% 3.9% is a blue-tinted near-black that feels less clinical.

--card / --popover: Surfaces that sit above the background. In dark mode, these need to be slightly lighter than --background to create depth. A 3-5% lightness bump is enough. More than that and your cards look like they're floating too high.

--border: This is where most dark themes fall apart. Too bright and every card, input, and divider screams at you. Too dim and the UI loses structure. Aim for 12-18% lightness. The default 240 3.7% 15.9% is a good baseline.

--muted-foreground: Your secondary text color. In light mode, this is a medium gray. In dark mode, it needs to sit around 55-65% lightness for comfortable readability without competing with primary text.

A custom dark theme example

Here's a dark theme with an indigo accent that actually feels considered, not just inverted:

.dark { --background: 240 10% 4%; --foreground: 0 0% 95%; --card: 240 10% 6%; --card-foreground: 0 0% 95%; --popover: 240 10% 7%; --popover-foreground: 0 0% 95%; --primary: 239 84% 67%; /* indigo accent */ --primary-foreground: 0 0% 100%; --secondary: 240 5% 12%; --secondary-foreground: 0 0% 90%; --muted: 240 4% 12%; --muted-foreground: 240 5% 60%; --accent: 240 5% 14%; --accent-foreground: 0 0% 90%; --destructive: 0 63% 55%; --border: 240 4% 16%; --input: 240 4% 16%; --ring: 239 84% 67%; }

The key moves: background at 4% (not 0%), card and popover each step up 2-3%, borders at 16% (visible but not loud), muted-foreground at 60% (readable but clearly secondary), and the accent color stays vibrant because it doesn't need to dim on dark backgrounds.

Command
The launcher. Dark, fast, surgical.
Inter+Inter
darkdeveloperminimalmonochrome
View seed →

Common mistakes

Forgetting to set card and popover separately. If you only change --background and --foreground, your cards and dropdowns will inherit the light mode values and look broken. Set every variable in the .dark block.

Using the same accent saturation. Some accent colors that look great in light mode get washed out or become too aggressive in dark mode. Test your primary color on the actual dark background. You might need to bump saturation up or shift lightness by 5-10%.

Ignoring the destructive color. Red at 0 84% 60% (the light mode default) can be eye-searing on a dark background. Drop the saturation and lightness for dark mode. Something like 0 63% 55% reads as “danger” without burning a hole in your retinas.


That's shadcn dark mode. Two variable blocks, one class toggle, zero component changes. For the broader theory behind dark mode color systems, read Dark Mode Color System: The Complete Guide. To customize the rest of your globals.css, check How to Customize shadcn/ui globals.css. Or skip the manual work and start with a dark theme seed that exports both modes.

Ready to stop guessing?

One flip. Complete design system. Free CSS export.

Get a dark theme seed with one flip →