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)
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.
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.
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.