Switch Language
Toggle Theme

Complete Guide to shadcn/ui Installation and Theme Customization with CSS Variables

Honestly, when I first used shadcn/ui, I was confused by the “not an npm package” concept. Copy code into my project? That sounds too primitive, right?

After using it a few times, I realized this is actually what makes it powerful—you own all the component source code, modify it however you want, no version conflicts, no being locked into a component library’s design decisions.

Today let’s talk about shadcn/ui installation and theme customization, focusing on how to use CSS variables to build a branded design system. After reading this article, you should be able to set up the basics in 5 minutes, then spend an hour or so fine-tuning the theme to your liking.


1. Quick Installation: Two Approaches

For new projects, just run this command:

npx shadcn@latest init

You’ll get asked a bunch of questions: TypeScript or JavaScript? Which style? Default theme? It’s all interactive, just follow the prompts.

After installation, your project will have these new files:

  • components.json - configuration file
  • lib/utils.ts - utility functions
  • components/ui/ - component directory

Adding components is simple too, like a button:

npx shadcn@latest add button

Component code gets copied to components/ui/button.tsx, just import and use it.

Here’s a catch: If your project has been in development for a while, tailwind.config.js and globals.css might have a lot of custom code. shadcn’s init command will overwrite these files, so it’s best to install at the very beginning.

One blogger put it well: treat shadcn/ui as your project’s “first dependency”, don’t wait until later to add it. Learned that the hard way.

Approach 2: Manual Installation (For Existing Projects)

If your project is already established and CLI overwriting config is too risky, install manually.

Here’s the breakdown:

Step 1: Make sure Tailwind CSS is installed

shadcn components are styled with Tailwind, so install it first if you haven’t. The official docs are pretty clear on this.

Step 2: Install dependencies

npm install class-variance-authority clsx tailwind-merge
npm install lucide-react

class-variance-authority (CVA for short) is useful—you’ll need it later for component variants.

Step 3: Configure path aliases

Add this to tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Now you can import components like @/components/ui/button instead of writing ../../../ everywhere.

Step 4: Create components.json

Create this file in your project root:

{
  "style": "new-york",
  "rsc": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

That cssVariables: true line is key—it means we’re using CSS variables for theming, not Tailwind utility classes.

Step 5: Add styles

Add shadcn’s base styles to globals.css, we’ll cover this in detail when discussing themes.


2. Understanding the Theme System: How CSS Variables Work

shadcn/ui’s theme system is based on a simple convention: every color has both background and foreground variables.

What does that mean? Here’s an example:

:root {
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;
}

--primary is the button’s background color, --primary-foreground is the text color on the button. The benefit of this pairing is that changing one variable updates all related components.

CSS Variables List

shadcn/ui defines these variables by default:

VariablePurpose
--backgroundPage background
--foregroundPage text
--cardCard background
--card-foregroundCard text
--popoverPopup background
--popover-foregroundPopup text
--primaryPrimary color (buttons, links)
--primary-foregroundText on primary color
--secondarySecondary color
--secondary-foregroundText on secondary color
--mutedMuted background
--muted-foregroundMuted text
--accentAccent color
--accent-foregroundText on accent color
--destructiveDestructive actions (delete buttons)
--destructive-foregroundText on destructive color
--borderBorders
--inputInput fields
--ringFocus rings

Looks like a lot, but once you understand the background/foreground pattern, it’s easy to remember.

The Secret of HSL Format

You might notice shadcn’s color values aren’t in standard HSL format:

/* ❌ Standard HSL */
--primary: hsl(222.2, 47.4%, 11.2%);

/* ✅ shadcn format */
--primary: 222.2 47.4% 11.2%;

Why write it in this “bare” format?

Because Tailwind supports opacity modifiers, like bg-primary/50 for 50% transparent primary color. If your variable is in full hsl() format, this feature won’t work.

With the bare format, Tailwind automatically adds hsl() and opacity for you. Smart design.


3. Customizing Your Brand Theme

Method 1: Modify CSS Variables Directly

The simplest approach—open globals.css, find the :root section, change the color values.

For example, changing primary from default blue to purple:

:root {
  --primary: 270 60% 60%;
  --primary-foreground: 0 0% 100%;
}

.dark {
  --primary: 270 60% 70%;
  --primary-foreground: 0 0% 0%;
}

Save it, and all components using bg-primary will turn purple.

Method 2: Use OKLCH Color Space (Tailwind v4)

If you’re using Tailwind v4, consider OKLCH color space. Compared to HSL, OKLCH color perception is closer to human vision, generating more uniform color scales.

:root {
  --primary: oklch(0.6 0.2 270);
  --primary-foreground: oklch(0.98 0 0);
}

The three parameters of oklch(0.6 0.2 270) are:

  • 0.6 - lightness (0-1)
  • 0.2 - chroma (around 0-0.4)
  • 270 - hue angle (0-360)

Method 3: Use Online Tools

Think configuring colors is too much hassle? Use online tools.

I recommend this one: Shadcn Theme Generator

Pick a primary color, the tool automatically generates a complete CSS variable set, including both light and dark versions. Copy-paste into globals.css and you’re done.


4. Dark Mode Configuration

Using next-themes for Theme Switching

shadcn/ui doesn’t include theme switching out of the box, but you can use the next-themes library.

Install it first:

npm install next-themes

Then configure in layout.tsx:

import { ThemeProvider } from "next-themes"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Key points:

  • suppressHydrationWarning is required, otherwise you’ll get hydration warnings
  • attribute="class" means using class names to switch themes
  • defaultTheme="system" means follow system preference by default
  • enableSystem enables system theme detection

Creating a Theme Toggle Button

Use the useTheme hook to get current theme and switch function:

import { useTheme } from "next-themes"
import { Moon, Sun } from "lucide-react"

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

  return (
    <button
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      className="p-2 rounded-md hover:bg-accent"
    >
      {theme === "dark" ? <Sun size={20} /> : <Moon size={20} />}
    </button>
  )
}

Default Dark Mode

If you want your site to default to dark mode, there are two ways:

Way 1: Hardcode dark class

<html lang="en" className="dark">

This locks the theme to dark, no switching.

Way 2: Set default theme

<ThemeProvider
  attribute="class"
  defaultTheme="dark"  // Default dark
  enableSystem={false} // Disable system detection
>

Users can still manually switch, but initial state is dark.


5. Advanced Customization: Component Variants

Creating Custom Variants with CVA

Sometimes you need to add multiple styles to buttons, like “danger”, “success”, “gradient”. CVA makes defining these variants easy.

import { cva, type VariantProps } from "class-variance-authority"

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        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",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

Then use it in components:

<button className={buttonVariants({ variant: "destructive", size: "lg" })}>
  Delete
</button>

Don’t Modify shadcn Component Source Code Directly

This is a best practice issue.

shadcn’s component code is in your own project, you can modify it however you want. But I recommend not modifying the original files—create wrapper components instead.

Why? shadcn frequently updates components. If you modify the original files, you’ll have to manually merge when updating. That’s a pain.

Better approach:

// components/brand-button.tsx
import { Button } from "@/components/ui/button"
import { cva } from "class-variance-authority"

const brandButtonVariants = cva("...", {
  variants: {
    brand: {
      primary: "bg-brand-primary text-white",
      secondary: "bg-brand-secondary text-black",
    },
  },
})

export function BrandButton({ brand, ...props }) {
  return <Button className={brandButtonVariants({ brand })} {...props} />
}

This way the original Button component stays unchanged, you’ve created your own BrandButton, and future shadcn updates won’t affect your customizations.


6. Common Issues and Gotchas

Issue 1: Styles Not Working After Installation

Check these things:

  1. Is globals.css imported in layout.tsx?
  2. Does Tailwind’s content config include components/**/*?
  3. Are the paths in components.json correct?

Issue 2: Theme Switching Flicker

This is usually caused by hydration mismatch. Make sure:

  1. <html> tag has suppressHydrationWarning
  2. ThemeProvider wraps the entire app
  3. Don’t read theme during server-side rendering (will be undefined)

Issue 3: CSS Variables Not Working

Possible causes:

  1. Typo in variable name (it’s --primary-foreground not --primaryForeground)
  2. Missing corresponding .dark styles
  3. Wrong variable value format (use bare HSL or OKLCH)

Issue 4: Component Style Conflicts

If your project already has a style system, it might conflict with shadcn’s. Solutions:

  1. Add namespace to shadcn components (like shadcn-button)
  2. Adjust Tailwind layer priorities
  3. Create your own variants with CVA, don’t rely on default styles

7. Summary

shadcn/ui installation and configuration is actually pretty simple—the key is understanding its “copy code not dependency package” design philosophy. The benefit is complete control, the downside is maintaining your own component code in every project.

For theme customization, the CSS variable system is elegantly designed—change a few variable values and the entire app’s colors update. With next-themes, light/dark mode switching is just a few lines of code.

Final recommendations:

  1. Use CLI initialization for new projects—skip the manual configuration hassle
  2. Use semantic color variables like primary, secondary, not specific color names
  3. Test contrast in both light and dark modes—ensure readability
  4. Create wrapper components instead of modifying source—makes updates easier

Next time you need to quickly build a themed UI, give shadcn/ui a try. The joy of copy-paste, you’ll understand once you use it.


shadcn/ui Installation and Theme Customization

Install shadcn/ui from scratch, configure theme system, build branded design

⏱️ Estimated time: 30 min

  1. 1

    Step1: CLI Quick Initialization

    Run the installation command in a new project:

    • npx shadcn@latest init
    • Choose TypeScript/New York style/default theme
    • Wait for CLI to finish configuration
  2. 2

    Step2: Modify Brand Primary Color

    Edit CSS variables in globals.css:

    • Open app/globals.css
    • Find --primary variable under :root
    • Change to your brand color (HSL or OKLCH format)
    • Also modify --primary-foreground for contrast
  3. 3

    Step3: Configure Dark Mode

    Install and configure next-themes:

    • npm install next-themes
    • Add ThemeProvider in layout.tsx
    • Set suppressHydrationWarning to avoid hydration warning
    • Create theme toggle component
  4. 4

    Step4: Create Component Variants

    Use CVA to define custom styles:

    • Install class-variance-authority
    • Define variants and defaultVariants
    • Apply buttonVariants() in components
    • Keep original shadcn components unchanged

FAQ

What's the difference between shadcn/ui and traditional UI component libraries?
shadcn/ui is not an npm package—it copies component source code into your project. The benefit is complete control and customization without version conflicts; the downside is maintaining component code in every project.
Why install shadcn/ui when initializing a new project?
Because shadcn's init command overwrites tailwind.config.js and globals.css. If your project has been in development, configurations in these files will be overwritten, so install as early as possible.
Why use bare format for CSS variables instead of standard HSL?
Bare format (like 222.2 47.4% 11.2%) supports Tailwind's opacity modifiers, such as bg-primary/50 for 50% transparency. Full hsl() format doesn't support this feature.
How do I change the brand primary color?
Open globals.css, find --primary and --primary-foreground variables under :root, change to your brand color values. After saving, all components using bg-primary will automatically update.
Why does dark mode flicker?
Usually caused by hydration mismatch. Make sure the html tag has suppressHydrationWarning attribute, ThemeProvider correctly wraps the entire app, and don't read theme during server-side rendering.
Should I modify shadcn component source code directly?
Not recommended. shadcn frequently updates components—if you modify original files, you'll need to manually merge during updates. Better to create wrapper components and keep originals unchanged.

References

8 min read · Published on: Mar 26, 2026 · Modified on: Mar 26, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts