How to Customize Shadcn UI Themes with CSS Variables
Step-by-step guide to customizing Shadcn UI themes using CSS variables. Learn the HSL color format, create custom color palettes, configure dark mode, adjust border radius and fonts, and apply themes globally across your React or Next.js project.
SERP Blocks Team
Product
One of the strongest reasons developers choose Shadcn UI over traditional component libraries is theming control. Instead of fighting against opinionated design tokens buried inside node_modules, Shadcn UI exposes every visual decision through CSS variables that you define in your own stylesheet. Change a color variable, and every component in your project updates instantly. No prop drilling, no theme provider wrappers, no build step required.
This guide covers the complete theming system: how CSS variables work in Shadcn UI, the HSL color format it uses, how to create a custom color palette from scratch, dark mode configuration, border radius and font customization, and how pre-built block libraries like SERP Blocks inherit your custom theme automatically.
Understanding the CSS Variable System
When you initialize Shadcn UI in a project with npx shadcn@latest init, it generates a set of CSS variables in your global stylesheet (typically globals.css or app.css). These variables define every color, radius, and visual token that components reference.
Here's what the default theme looks like:
@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 Shadcn UI component references these variables through Tailwind utility classes. For example, the Button component uses bg-primary text-primary-foreground for its default variant, which resolves to whatever values you've set for --primary and --primary-foreground.
The HSL Color Format
Shadcn UI uses a specific format for color values: HSL values without the hsl() function wrapper. Instead of writing hsl(240, 5.9%, 10%), you write 240 5.9% 10%. The hsl() wrapper is added by Tailwind's configuration.
This separation exists so Tailwind can apply opacity modifiers. When you write bg-primary/50 in a className, Tailwind needs to inject the opacity value inside the color function. By storing only the raw HSL values, this composition works cleanly:
/* What you define */
--primary: 240 5.9% 10%;
/* What Tailwind generates when you use bg-primary/50 */
background-color: hsl(240 5.9% 10% / 0.5);HSL Refresher
If you're used to thinking in hex codes, HSL takes a moment to internalize:
H (Hue): 0-360, the position on the color wheel. 0 is red, 120 is green, 240 is blue, 360 wraps back to red.
S (Saturation): 0%-100%, the intensity of the color. 0% is gray, 100% is the purest version of that hue.
L (Lightness): 0%-100%, how light or dark the color is. 0% is black, 100% is white, 50% is the "true" color.
The advantage of HSL for theming is that you can create entire color palettes by fixing the hue and varying saturation and lightness. This makes light/dark mode pairs intuitive: keep the same hue, increase lightness for light mode backgrounds, decrease it for dark mode.
Creating a Custom Color Palette
Let's build a custom theme from scratch. Say your brand color is a deep blue with hex #2563eb (Tailwind's blue-600). In HSL, that's approximately 217 91% 53%.
Step 1: Define Your Primary Color
Start with the primary color and its foreground (the text color used on top of primary backgrounds):
:root {
--primary: 217 91% 53%;
--primary-foreground: 0 0% 100%;
}The foreground should have high contrast against the primary background. For a medium-dark blue, white (0 0% 100%) works well.
Step 2: Build the Background and Surface Colors
Your background is the base page color. Card and popover backgrounds are "surface" colors that sit above the background:
:root {
--background: 210 40% 98%;
--foreground: 217 33% 17%;
--card: 0 0% 100%;
--card-foreground: 217 33% 17%;
--popover: 0 0% 100%;
--popover-foreground: 217 33% 17%;
}Notice the background has a slight blue tint (210 hue, 40% saturation) rather than pure white. This creates a subtle brand-consistent feel. Cards and popovers are pure white to create elevation contrast against the tinted background.
Step 3: Define Secondary and Muted Colors
Secondary colors are used for less prominent actions (secondary buttons, badges). Muted colors are for de-emphasized text and backgrounds:
:root {
--secondary: 214 32% 91%;
--secondary-foreground: 217 33% 17%;
--muted: 214 32% 91%;
--muted-foreground: 215 16% 47%;
--accent: 214 32% 91%;
--accent-foreground: 217 33% 17%;
}These use the same blue hue family (214-217) with very low saturation and high lightness. They're subtle enough to not compete with your primary color.
Step 4: Set Border, Input, and Ring Colors
:root {
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 217 91% 53%;
}The ring color (focus outline) should match your primary color so focus states feel intentional.
Step 5: Set the Border Radius
:root {
--radius: 0.5rem;
}This single variable controls the border radius of every component. Common values:
0rem— Sharp, square corners (brutalist/minimal aesthetic)0.25rem— Subtle rounding0.5rem— The Shadcn default, balanced and modern0.75rem— Softer, more approachable feel1rem— Very rounded, playful aesthetic
Dark Mode Theming
Dark mode in Shadcn UI works by defining a second set of CSS variables under the .dark class selector. When the dark class is applied to the <html> element, all components automatically switch to the dark palette.
Here's the dark mode counterpart for our custom blue theme:
.dark {
--background: 217 33% 8%;
--foreground: 210 40% 98%;
--card: 217 33% 12%;
--card-foreground: 210 40% 98%;
--popover: 217 33% 12%;
--popover-foreground: 210 40% 98%;
--primary: 217 91% 60%;
--primary-foreground: 217 33% 8%;
--secondary: 217 20% 18%;
--secondary-foreground: 210 40% 98%;
--muted: 217 20% 18%;
--muted-foreground: 215 16% 57%;
--accent: 217 20% 18%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 217 20% 18%;
--input: 217 20% 18%;
--ring: 217 91% 60%;
}The pattern is straightforward: backgrounds become dark (low lightness), foregrounds become light (high lightness), and the primary color shifts slightly brighter to maintain contrast against dark surfaces.
Dark Mode Toggle
To toggle dark mode, add or remove the dark class on the <html> element. The standard approach in Next.js uses next-themes:
npm install next-themes// app/layout.tsx
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
</body>
</html>
);
}Then create a theme toggle component:
"use client";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-react";
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" />
</Button>
);
}Font Customization
Shadcn UI uses the font-sans utility class, which maps to your Tailwind font family configuration. To change the font across all components, update your Tailwind config and load the font:
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}To use a different font — say, Geist or DM Sans — just swap the import:
import { DM_Sans } from "next/font/google";
const dmSans = DM_Sans({ subsets: ["latin"], variable: "--font-sans" });Every Shadcn component that uses font-sans will pick up the new font automatically.
Applying Themes to Pre-Built Blocks
One of the most practical benefits of CSS variable theming is that any component built on Shadcn UI primitives automatically inherits your theme. This includes pre-built block libraries.
SERP Blocks ships 1200+ blocks across 50+ categories — hero sections, feature grids, pricing tables, card layouts, dashboard panels, and more. Every block uses Shadcn UI's semantic color classes (bg-primary, text-muted-foreground, border-border) rather than hardcoded color values. This means when you paste a SERP Block into your project, it immediately uses your custom theme.
For example, a CTA section with a bg-primary background will render in your brand's blue (or green, or purple, or whatever you've set --primary to). The text on that background uses text-primary-foreground, which you've already defined to have sufficient contrast. Dark mode works automatically because you've defined both :root and .dark variable sets.
SERP Blocks also supports 40 built-in themes that you can preview on the site. These are useful starting points — you can apply one that's close to your brand and then fine-tune individual variables.
Creating Multiple Theme Variants
You're not limited to a single light and dark theme. You can create multiple theme variants by scoping CSS variables to different class names or data attributes:
[data-theme="ocean"] {
--primary: 199 89% 48%;
--primary-foreground: 0 0% 100%;
--background: 200 30% 97%;
--foreground: 200 50% 10%;
/* ... remaining variables */
}
[data-theme="forest"] {
--primary: 142 76% 36%;
--primary-foreground: 0 0% 100%;
--background: 140 20% 97%;
--foreground: 140 40% 10%;
/* ... remaining variables */
}
[data-theme="sunset"] {
--primary: 25 95% 53%;
--primary-foreground: 0 0% 100%;
--background: 30 30% 97%;
--foreground: 20 40% 10%;
/* ... remaining variables */
}Then switch themes by updating the data-theme attribute on your root element:
document.documentElement.setAttribute("data-theme", "ocean");This approach is useful for SaaS products that offer white-labeling or user-customizable themes. Each tenant or user can have their own theme applied through a single attribute change, and every Shadcn component — plus every SERP Block — updates instantly.
Common Theming Mistakes
Not defining foreground pairs
Every background variable needs a corresponding foreground variable with sufficient contrast. If you set --primary to a dark blue and forget to set --primary-foreground to a light color, any button text rendered with text-primary-foreground will be invisible against its background.
Using inconsistent hue families
A cohesive theme uses 1-2 hue families. Your primary, secondary, muted, and accent colors should share similar hue ranges (or be intentionally complementary). Mixing random hues creates a chaotic visual experience.
Forgetting the destructive color
The --destructive variable is used for error states, delete buttons, and warning alerts. Many custom themes skip it, leaving the default red in place. That's usually fine — red for danger is universal — but make sure the destructive color doesn't clash with your primary palette.
Making dark mode backgrounds too dark
Pure black (0 0% 0%) backgrounds create harsh contrast with text and strain the eyes. Most modern dark themes use a very dark gray with a slight color tint. Something in the 5-12% lightness range works well.
Testing Your Theme
After defining your CSS variables, verify them across the component library:
Buttons — Check all variants: default, secondary, outline, ghost, destructive
Cards — Verify background contrast against the page
Forms — Test input borders, focus rings, placeholder text
Dialogs and popovers — Ensure they have visible elevation
Tables — Check alternating row backgrounds and header contrast
Navigation — Verify active states and hover colors
Test both light and dark modes at every step. The most common issue is a variable that looks fine in light mode but has insufficient contrast in dark mode.
Quick Reference: All CSS Variables
Here's the complete list of CSS variables Shadcn UI uses, with their purpose:
| Variable | Purpose |
--background | Page background |
--foreground | Default text color |
--card | Card background |
--card-foreground | Text on cards |
--popover | Popover/dropdown background |
--popover-foreground | Text in popovers |
--primary | Primary actions, buttons, links |
--primary-foreground | Text on primary backgrounds |
--secondary | Secondary actions |
--secondary-foreground | Text on secondary backgrounds |
--muted | Muted/subtle backgrounds |
--muted-foreground | De-emphasized text |
--accent | Hover/highlight backgrounds |
--accent-foreground | Text on accent backgrounds |
--destructive | Error/danger states |
--destructive-foreground | Text on destructive backgrounds |
--border | Default border color |
--input | Input field borders |
--ring | Focus ring color |
--radius | Global border radius |
Changing any of these instantly updates every Shadcn component and any block library built on Shadcn primitives, including the full SERP Blocks collection of 1200+ blocks.
Next Steps
With your custom theme in place, every block you add to your project — whether built by hand or copied from a library — will match your brand. Start building with themed blocks:
Browse 110 card components that inherit your theme
Explore 81 hero sections in your brand colors
Preview 132 feature layouts with your custom palette
Check out 21 FAQ sections with your border radius
60 blocks are free across the library. The Pro plan unlocks all 1200+ with a one-time purchase.