Theming with shadcn/ui

12 min readJan 4, 2026

Morgan Feeney

Digital craftsman

My experience setting up theming with shadcn/ui, Tailwind v4, and CSS variables

I recently set up theming for a Next.js project and I won't lie, the whole CSS variable system clicked for me once I understood how shadcn/ui approaches it. This isn't a comprehensive guide—just my notes on what worked, what confused me, and what I wish I'd known from the start.

Why I like shadcn/ui's approach

The main thing is that shadcn/ui doesn't give you pre-built components you import from a package. You literally copy the code into your project. This means when something like Tailwind v4 comes out, you're not stuck waiting for a library update—you just follow the Tailwind docs and update your own code.

Plus, the theming system is stupidly simple once you get it: everything's just CSS variables. No complex configuration files, no theme providers with confusing APIs. Just CSS.

Getting started

I ran this to add shadcn/ui to my existing Next.js project:

pnpm dlx shadcn@latest init

The CLI asked me just one question:

Which color would you like to use as base color? › Zinc

That's it! The CLI now automatically detects:

  • Your framework (Next.js, Vite, etc.)
  • Your global CSS file location
  • Whether you're using TypeScript

It also defaults to:

  • "New York" style (the "default" style is deprecated)
  • CSS variables enabled (which is what you want for theming)
  • Tailwind v4 for new projects
  • React 19 support

Way simpler than it used to be.

The CLI created a components.json file:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "registries": {}
}

The naming convention that confused me

Here's what tripped me up initially: shadcn/ui uses a "background/foreground" naming pattern, and the background suffix is omitted when you actually use the utility class.

So if you have these variables:

--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);

You use them like this:

<Button className="bg-primary text-primary-foreground">
  Click me
</Button>

Notice: bg-primary not bg-primary-background. The background color is implied.

This makes sense once you internalize it: the base name (primary) is always the background, and you explicitly add -foreground when you need the text color.

Why this matters: I initially tried to use --primary for all my purple brand colors throughout the site. Wrong! --primary is mostly for primary buttons. If you want a custom purple for other things, add your own variable like --brand or --accent-purple.

Understanding what each variable is for

After reading the docs and actually using these, here's what each variable is meant for:

Core colors

/* The main background - your body/page background */
--background: oklch(1 0 0);

/* Text color on the main background */
--foreground: oklch(0.141 0.005 285.823);

Card components

/* Background for card-like containers */
--card: oklch(1 0 0);

/* Text color on cards */
--card-foreground: oklch(0.141 0.005 285.823);

Use these for any elevated content like article cards, product cards, etc.

Popovers & dropdowns

/* Background for floating elements */
--popover: oklch(1 0 0);

/* Text color in popovers */
--popover-foreground: oklch(0.141 0.005 285.823);

This is for dropdown menus, hover cards, tooltips—anything that floats above the page.

Primary actions

/* Your main call-to-action color */
--primary: oklch(0.21 0.006 285.885);

/* Text color on primary buttons */
--primary-foreground: oklch(0.985 0 0);

This is for buttons, not for branding. Use it for "Sign Up", "Submit", "Buy Now" buttons.

Secondary actions

/* Less prominent actions */
--secondary: oklch(0.967 0.001 286.375);

/* Text color on secondary buttons */
--secondary-foreground: oklch(0.21 0.006 285.885);

Secondary buttons, outline buttons, less important CTAs.

Muted content

/* Subtle backgrounds */
--muted: oklch(0.967 0.001 286.375);

/* Text for less important content */
--muted-foreground: oklch(0.552 0.016 285.938);

I use muted for inactive tabs, disabled form fields, placeholder text. muted-foreground is perfect for timestamps, captions, helper text.

Accent/hover states

/* Hover backgrounds for menu items, table rows */
--accent: oklch(0.967 0.001 286.375);

/* Text color when hovering */
--accent-foreground: oklch(0.21 0.006 285.885);

This gets applied when you hover over dropdown items or table rows.

Destructive actions

/* Delete, remove, danger actions */
--destructive: oklch(0.577 0.245 27.325);

/* Text on destructive buttons */
--destructive-foreground: oklch(0.985 0 0);

Anything that's dangerous or permanent should use this.

Borders & inputs

/* Border color for most elements */
--border: oklch(0.92 0.004 286.32);

/* Border specifically for form inputs */
--input: oklch(0.92 0.004 286.32);

Often these are the same, but you can differentiate if needed.

Focus rings

/* The outline when focusing elements */
--ring: oklch(0.705 0.015 286.067);

Shows up when you tab through interactive elements.

Charts (if you use them)

--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);

These give you 5 colors for data visualization. I haven't used these yet but they're there.

--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);

If you're using the sidebar component, these control its colors independently from the rest of your app.

Radius

/* Border radius for buttons, cards, inputs */
--radius: 0.625rem;

Not a color but part of the theme. Change this one value and all your components get rounder or sharper.

The complete variable list

Here's everything you can customize, in one place. This is the Zinc color scheme (which is what I used):

@import "tailwindcss";

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --color-chart-1: var(--chart-1);
  --color-chart-2: var(--chart-2);
  --color-chart-3: var(--chart-3);
  --color-chart-4: var(--chart-4);
  --color-chart-5: var(--chart-5);
  --color-sidebar: var(--sidebar);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-ring: var(--sidebar-ring);
  --radius: var(--radius);
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.141 0.005 285.823);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.141 0.005 285.823);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.141 0.005 285.823);
  --primary: oklch(0.21 0.006 285.885);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.967 0.001 286.375);
  --secondary-foreground: oklch(0.21 0.006 285.885);
  --muted: oklch(0.967 0.001 286.375);
  --muted-foreground: oklch(0.552 0.016 285.938);
  --accent: oklch(0.967 0.001 286.375);
  --accent-foreground: oklch(0.21 0.006 285.885);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.92 0.004 286.32);
  --input: oklch(0.92 0.004 286.32);
  --ring: oklch(0.705 0.015 286.067);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.141 0.005 285.823);
  --sidebar-primary: oklch(0.21 0.006 285.885);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.967 0.001 286.375);
  --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
  --sidebar-border: oklch(0.92 0.004 286.32);
  --sidebar-ring: oklch(0.705 0.015 286.067);
}

.dark {
  --background: oklch(0.141 0.005 285.823);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.21 0.006 285.885);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.21 0.006 285.885);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.92 0.004 286.32);
  --primary-foreground: oklch(0.21 0.006 285.885);
  --secondary: oklch(0.274 0.006 286.033);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.274 0.006 286.033);
  --muted-foreground: oklch(0.705 0.015 286.067);
  --accent: oklch(0.274 0.006 286.033);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.552 0.016 285.938);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.21 0.006 285.885);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.274 0.006 286.033);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.552 0.016 285.938);
}

Adding your own colors

I needed a warning color for alerts and notifications. Here's how I added it:

@theme inline {
  /* All the default theme mappings above... */
  --color-warning: var(--warning);
  --color-warning-foreground: var(--warning-foreground);
}

:root {
  /* All the default colors above... */
  --warning: oklch(0.84 0.16 84);
  --warning-foreground: oklch(0.28 0.07 46);
}

.dark {
  /* All the default dark colors above... */
  --warning: oklch(0.41 0.11 46);
  --warning-foreground: oklch(0.99 0.02 95);
}

Now I can use it:

<div className="bg-warning text-warning-foreground">
  Warning: This action cannot be undone
</div>

That's it. No tailwind.config.js changes needed with Tailwind v4.

The Tailwind v4 setup

The @theme inline directive is specific to Tailwind v4. Here's why it's set up this way:

  1. We define our colors as regular CSS variables (:root and .dark)
  2. We wrap them with actual color values (like oklch(...))
  3. We use @theme inline to expose them to Tailwind's utility classes
  4. Inside @theme inline, we reference the CSS variables we just defined

This might seem redundant, but it gives you flexibility. You can:

  • Use these colors in regular CSS: color: var(--primary)
  • Use them in Tailwind classes: className="bg-primary"
  • Access them in JavaScript: getComputedStyle(el).getPropertyValue('--primary')

OKLCH vs HSL

shadcn/ui now uses OKLCH instead of HSL. I'm not going to pretend I understand the color science, but here's what matters:

  • OKLCH gives you more vibrant colors on modern displays
  • The syntax is oklch(lightness chroma hue)
  • Gradients look better because the color transitions are more perceptually uniform

You can still use HSL if you want:

--primary: hsl(222.2 47.4% 11.2%);

But OKLCH is the recommended default now.

Other base color options

Don't like Zinc? shadcn/ui provides 5 base color schemes:

  • Neutral - True grayscale (no color tint)
  • Stone - Warm gray with a brown tint
  • Zinc - Cool gray with a blue tint (my choice)
  • Gray - Medium blue-gray
  • Slate - Strong blue-gray

If you need more fin-grained control head on over to the shadcn theme generator.

Dark mode

Dark mode "just works" because of the .dark class. I use next-themes to toggle it:

import { ThemeProvider } from "next-themes"

export function Providers({ children }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system">
      {children}
    </ThemeProvider>
  )
}

When the .dark class is applied to the root element, all the dark mode CSS variables kick in automatically.

What I wish I'd known earlier

  1. Don't use --primary for your brand color. It's for primary buttons. Add a --brand variable if you need a brand color.

  2. The foreground colors aren't optional. Every background needs a matching foreground for accessible contrast.

  3. You can use these variables outside of components. They're just CSS variables, so use them in your own CSS files.

  4. The @theme inline directive is Tailwind v4 only. If you're still on v3, the setup is different (but you should upgrade).

  5. Test in dark mode constantly. It's easy to get light mode looking perfect and then realize dark mode is unreadable.

Useful tools

  • ui.shadcn.com/colors - Official color reference with all formats
  • shadcn ui theme generator - Generate complete themes from a single color
  • Browser DevTools - Inspect element and modify CSS variables in real-time to find what works

Final thoughts

The whole CSS variable system felt over-engineered at first, but it clicked when I needed to add dark mode. Changing one file updated my entire app. That's the power of this approach.

If you're starting fresh, just use the defaults and add custom colors as you need them. Don't overthink it.