OneMinuteBrandingOneMinuteBrandingGenerate Brand
  1. Home
  2. Blog
  3. How to Create a Custom shadcn/ui Theme (Complete Guide)
shadcntailwindthemingdevelopers

How to Create a Custom shadcn/ui Theme (Complete Guide)

shadcn/ui ships with a default theme. Here's how to replace it with your brand colors and make every component match your identity.

March 15, 202611 min readBy Yann Lephay

You run npx shadcn-ui@latest init. You pick the "Zinc" base color. You scaffold your dashboard, deploy to Vercel, and step back. Your SaaS now looks exactly like the last 400 projects submitted to Product Hunt. It screams "I am a developer who hates design."

You open globals.css and try to fix it. You paste #3B82F6 over the --primary variable. Your build compiles, but your buttons lose their hover states, your gradients break, and your dark mode becomes unreadable.

shadcn/ui does not use standard CSS hex codes. It uses a semantic variable system built on raw HSL values hooked directly into Tailwind's opacity engine. If you don't understand how the --primary-foreground maps to --primary, you cannot theme your application.

Here is exactly how shadcn/ui handles theming, how to build a custom semantic color palette, and the exact files you need to modify to replace the default template with your brand identity.

The shadcn/ui CSS Variable Architecture

shadcn/ui relies on a handshake between globals.css and tailwind.config.ts. The system uses raw HSL (Hue, Saturation, Lightness) values without the hsl() wrapper.

Open your globals.css. You will see this:

Code
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;
    --primary-foreground: 210 40% 98%;
    --radius: 0.5rem;
  }
}
Notice the raw numbers: `221.2 83.2% 53.3%`. This specific format is required because of how Tailwind CSS compiles opacity modifiers. 
 
When you write `<div class="bg-primary/50">` in your React component, Tailwind looks at your `tailwind.config.ts`, sees that `primary` maps to `var(--primary)`, and compiles the final CSS as `background-color: hsl(221.2 83.2% 53.3% / 0.5)`. 
 
If you put `#3B82F6` or `rgb(59, 130, 246)` in that CSS variable, Tailwind cannot inject the `/ 0.5` alpha channel. Your opacity modifiers fail silently. The background renders at 100% opacity, breaking the visual hierarchy of components like disabled buttons or focus rings.
 
## Mapping Your Brand to Semantic Slots
 
You do not define colors in shadcn by their appearance (e.g., `--blue-500`). You define them by their UI function. Every shadcn component maps to one of 12 semantic slots. 
 
If you assign your vibrant brand purple to `--accent`, you will ruin your application. The table below defines exactly what each variable styles in the DOM.
 
| Variable | UI Function | Light Mode Target | Dark Mode Target |
| :--- | :--- | :--- | :--- |
| `--background` | The main `<body>` background. | White or 0-2% saturation. | 3-5% lightness, 5-10% saturation. |
| `--foreground` | Default text color. | 5-10% lightness (near black). | 90-98% lightness (near white). |
| `--card` | Background of `<Card>` components. | Identical to `--background`. | 1-2% lighter than `--background`. |
| `--popover` | Background of Dropdowns, Selects, Tooltips. | Identical to `--background`. | Identical to `--card`. |
| `--primary` | Primary buttons, active states, checkboxes. | Brand color (Shade 600). | Brand color (Shade 500). |
| `--primary-foreground`| Text inside primary buttons. | White (Shade 50) or Black. | White (Shade 50) or Black. |
| `--secondary` | Secondary buttons, subtle backgrounds. | Shade 100 of your brand. | Shade 800 of your brand. |
| `--muted` | Disabled states, skeleton loaders. | Shade 100 (desaturated). | Shade 800 (desaturated). |
| `--muted-foreground` | Helper text, disabled text, placeholders. | Shade 500 (desaturated). | Shade 400 (desaturated). |
| `--accent` | **Hover states** on dropdowns and lists. | Shade 100 of your brand. | Shade 800 of your brand. |
| `--destructive` | Error states, delete buttons. | Red (Shade 600). | Red (Shade 500 or 400). |
| `--border` | Default `<hr>`, card borders, inputs. | Shade 200. | Shade 800. |
| `--ring` | Focus outline on inputs and buttons. | Brand color (Shade 600). | Brand color (Shade 500). |
 
The most common mistake developers make is treating `--accent` as a highlight color. In shadcn, `--accent` is exclusively used for subtle hover backgrounds, like when you mouse over an item in a `<DropdownMenu>`. If you make it bright pink, your users will see blinding pink blocks every time they open a select menu.
 
## Step-by-Step: Replacing the Default Theme
 
To replace the default theme, you need to generate a full 11-step color scale (50 to 950) for your brand color, convert those hex codes to raw HSL, and map them to the semantic slots.
 
### Step 1: Generate your HSL scale
Take your brand hex code (e.g., `#4F46E5`). You need to generate the lighter and darker shades. You cannot just guess these numbers. You need a mathematically distributed scale to ensure contrast ratios pass WCAG AA standards (4.5:1).
 
If you want to skip manual color math, use OneMinuteBranding. You type your product name, and it outputs the exact `globals.css`, `tailwind.config.ts`, and design tokens for your brand in 60 seconds. It costs $49 one-time. 
 
If you are doing this manually, use a tool like UI Colors to generate the 50-950 scale, then run each hex code through a Hex-to-HSL converter. Strip the `hsl()` wrapper and the `%` signs if you are using older Tailwind versions, but modern shadcn setups accept the `%` sign.
 
### Step 2: Write the Light Mode Variables
Open `app/globals.css`. Target the `:root` pseudo-class. Map your newly generated HSL values to the semantic slots. 
 
For a custom "Indigo" brand theme, your `:root` should look exactly like this:
 
```css
@layer base {
  :root {
    /* Backgrounds: Pure white */
    --background: 0 0% 100%;
    --foreground: 226 58% 10%;
 
    /* Cards and Popovers: White */
    --card: 0 0% 100%;
    --card-foreground: 226 58% 10%;
    --popover: 0 0% 100%;
    --popover-foreground: 226 58% 10%;
 
    /* Primary: Indigo 600 */
    --primary: 238 84% 59%;
    --primary-foreground: 210 40% 98%;
 
    /* Secondary: Indigo 100 */
    --secondary: 238 86% 94%;
    --secondary-foreground: 238 84% 59%;
 
    /* Muted: Slate 100 */
    --muted: 210 40% 96%;
    --muted-foreground: 215.4 16.3% 46.9%;
 
    /* Accent: Indigo 50 */
    --accent: 238 86% 97%;
    --accent-foreground: 238 84% 59%;
 
    /* Destructive: Red 600 */
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
 
    /* Borders and Rings */
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 238 84% 59%;
 
    --radius: 0.75rem;
  }
}

Notice the --radius variable. This controls the border-radius for every single shadcn component. 0.5rem is the default. Change it to 0rem for a brutalist look, or 0.75rem for a softer, modern SaaS aesthetic.

Step 3: Write the Dark Mode Variables

Dark mode requires desaturation. If you use your light mode --primary (Indigo 600) in dark mode, it will vibrate against the dark background and cause eye strain. You must shift your primary color to a lighter, less saturated shade (Indigo 500 or 400).

Target the .dark class in the same globals.css file:

Code
@layer base {
  .dark {
    /* Backgrounds: Deep Indigo/Slate */
    --background: 226 58% 4%;
    --foreground: 210 40% 98%;
 
    /* Cards: Slightly lighter than background */
    --card: 226 58% 6%;
    --card-foreground: 210 40% 98%;
    --popover: 226 58% 6%;
    --popover-foreground: 210 40% 98%;
 
    /* Primary: Indigo 500 (lighter than light mode) */
    --primary: 238 84% 65%;
    --primary-foreground: 226 58% 4%;
 
    /* Secondary: Indigo 900 */
    --secondary: 238 50% 16%;
    --secondary-foreground: 210 40% 98%;
 
    /* Muted: Slate 800 */
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
 
    /* Accent: Indigo 900/50 */
    --accent: 238 50% 16%;
    --accent-foreground: 210 40% 98%;
 
    /* Destructive: Red 500 */
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;
 
    /* Borders and Rings */
    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 238 84% 65%;
  }
}

Step 4: Configure Tailwind

Your CSS variables are meaningless if Tailwind doesn't know they exist. shadcn's installation script handles the base configuration, but if you add custom radii or custom semantic slots, you must update tailwind.config.ts.

Ensure your extend.colors object maps exactly to the CSS variables.

Code
import type { Config } from "tailwindcss"
 
const config = {
  darkMode: ["class"],
  content: [
    './pages/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    './app/**/*.{ts,tsx}',
    './src/**/*.{ts,tsx}',
  ],
  prefix: "",
  theme: {
    container: {
      center: true,
      padding: "2rem",
      screens: {
        "2xl": "1400px",
      },
    },
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        destructive: {
          DEFAULT: "hsl(var(--destructive))",
          foreground: "hsl(var(--destructive-foreground))",
        },
        muted: {
          DEFAULT: "hsl(var(--muted))",
          foreground: "hsl(var(--muted-foreground))",
        },
        accent: {
          DEFAULT: "hsl(var(--accent))",
          foreground: "hsl(var(--accent-foreground))",
        },
        popover: {
          DEFAULT: "hsl(var(--popover))",
          foreground: "hsl(var(--popover-foreground))",
        },
        card: {
          DEFAULT: "hsl(var(--card))",
          foreground: "hsl(var(--card-foreground))",
        },
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
} satisfies Config
 
export default config

The borderRadius configuration is critical. shadcn components use rounded-lg, rounded-md, and rounded-sm. By mapping these to calc(var(--radius) - Xpx), you ensure that a <Card> and the <Button> inside it have proportionally matching border radii. A mathematically perfect nested curve requires the inner radius to equal the outer radius minus the padding between them.

Adding a Custom Semantic Color (e.g., Success)

shadcn/ui ships with --destructive for errors, but it lacks a --success variable for positive actions like "Save Changes" or "Payment Complete". You cannot just write bg-green-500. You must integrate it into the semantic system.

First, add the variables to globals.css:

Code
@layer base {
  :root {
    --success: 142.1 76.2% 36.3%;
    --success-foreground: 355.7 100% 97.3%;
  }
  .dark {
    --success: 142.1 70.6% 45.3%;
    --success-foreground: 144.9 80.4% 10%;
  }
}

Next, map it in tailwind.config.ts inside extend.colors:

Code
success: {
  DEFAULT: "hsl(var(--success))",
  foreground: "hsl(var(--success-foreground))",
},

Finally, modify the shadcn <Button> component to accept your new variant. Open components/ui/button.tsx. Locate the cva (class variance authority) declaration and add the success variant:

Code
const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        success: "bg-success text-success-foreground hover:bg-success/90", // Your new variant
        outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

Now you can write <Button variant="success">Save</Button> and it will automatically support dark mode, hover states, and opacity modifiers.

Common Pitfalls That Break Your UI

1. Failing the Muted Contrast Check

Developers routinely make --muted-foreground too light in light mode or too dark in dark mode. If your --muted background is 210 40% 96% (a very light gray), your --muted-foreground must have a lightness value of 45% or lower to pass the 4.5:1 contrast ratio. If you set it to 60%, users with astigmatism will not be able to read your placeholder text.

2. Using Pure Black in Dark Mode

Do not set dark mode --background to 0 0% 0%. Pure black causes severe halation (text blooming) on OLED screens. Your dark mode background should sit between 3% and 6% lightness. Use 226 58% 4% for a rich, deep blue-black that is vastly superior to pure #000000.

3. Hardcoding Hex Values in Components

Once you adopt the shadcn CSS variable system, you must stop using Tailwind's default color palette in your markup. If you write <span class="text-gray-500">, that text will look fine in light mode and become invisible in dark mode. You must use <span class="text-muted-foreground">. Rely entirely on the semantic variables.

FAQ

Why does shadcn/ui use raw numbers instead of hsl() or rgb()? Tailwind CSS requires raw values to dynamically inject the alpha channel for opacity modifiers. If you define --primary: hsl(200, 50%, 50%), Tailwind cannot append / 50% to it. By defining --primary: 200 50% 50%, Tailwind can compile bg-primary/50 into background-color: hsl(200 50% 50% / 0.5).

Do I need to update tailwind.config.ts if I just change the HSL values in globals.css? No. As long as you are modifying the existing variables (--primary, --muted, etc.), the default tailwind.config.ts generated by the shadcn CLI already maps them correctly. You only need to touch the config file if you add entirely new variables like --warning or --brand-tertiary.

How do I apply different themes to different parts of my app? Wrap the specific section in a <div> and apply a data attribute or class that overrides the CSS variables. shadcn's variables scope to whatever container they are applied to. If you define a .theme-admin class in your CSS with different HSL values, <div class="theme-admin"> will force all child shadcn components to use those new variables.

Why are my custom dark mode buttons unreadable? Your --primary-foreground does not have enough contrast against your --primary color. If your dark mode --primary is a light, desaturated blue (e.g., lightness > 60%), your --primary-foreground must be dark (226 58% 4%), not white.

Replace the variables in your globals.css right now. Run npm run dev. Render a <Button disabled>, a <Card>, and a <DropdownMenu>. Toggle your system preference to dark mode and check the hover state on the dropdown. If the text is legible and the contrast passes, your semantic theme is structurally sound.

Y
Yann Lephay@YannBuilds

Vibe coder & Indie Hacker. Building tools to help devs ship faster. Creator of OneMinuteBranding.

Ready to create your brand?

Generate a complete brand system with Tailwind config in 60 seconds.

Generate your brand

Related articles

The Developer's Brand Kit Checklist: 23 Files You Need Before Launch

Before you launch, make sure you have these 23 files. From tailwind.config.ts to OG images to CLAUDE.md. Copy this checklist.

The Complete Guide to Favicons, OG Images, and App Icons in 2026

17 different icon sizes, OG images, Apple touch icons. Here's every size you need, where each one goes, and how to generate them all from one source.

Font Pairing for Developers: 7 Combinations That Always Work

Stop spending 2 hours on Google Fonts. These 7 font pairs work for any SaaS product. With next/font code snippets.

Explore more

Branding by RoleBranding by IndustryUse CasesFeaturesIntegrationsGlossaryFree Tools
BlogAboutTermsPrivacy