Engineering
You Don't Use shadcn Instead of Tailwind
New projects often start with npx shadcn add and a foggy sense that Tailwind got replaced. It did not. Here is what you actually copied, what Radix is doing, and how to read any UI file in a shadcn codebase.
A lot of developers start a Next.js app, add shadcn because the starter said to, and then cannot answer a simple question: are we using Tailwind, or shadcn, or both? The docs talk past that confusion. Tutorials say "use shadcn" as if it were a styling system. Six months later the repo has a components/ui folder, a tailwind.config, and nobody on the team can draw the line between them.
That fog is reasonable. The names sound like alternatives. They are not. Tailwind is the vocabulary for CSS. shadcn is a set of React files you copy into your repo that speak that vocabulary — and, for anything harder than a button, wrap a behavior engine you did not write.
The one-line model
Tailwind paints. Radix behaves. shadcn is the skin you own — a copied React file that wires those two together. You never stop writing Tailwind. You sometimes stop writing a raw <button className="…"> because a prefab already did.
They Are Not Alternatives
"Should we use Tailwind or shadcn?" is the same kind of question as "should we use English or recipes?" One is the language. The other is a recipe written in that language.
Three phrases get used as if they were three stacks. They are one layer cake, and the top layer is optional:
- We use Tailwind — we style by composing utility classes on markup we wrote.
- We use Tailwind and shadcn — same paint, but some widgets are copied components instead of one-off JSX.
- We only use shadcn — usually a misunderstanding. Those components are still Tailwind. The speaker means they rarely write utilities by hand on the page.
Material UI and Chakra are a different model: you import a packaged component and theme it. You do not typically own the source or write bg-coral px-8. shadcn is closer to a well-organized gist than to those libraries.
What Tailwind Is
Tailwind is a CSS toolkit. You put small utility classes on elements and compose a look yourself. No buttons, dialogs, or carousels come with it. There is no runtime component. The build scans your files, keeps the classes it finds, and emits CSS.
<button className="rounded-md bg-coral px-8 py-3 font-semibold text-white hover:bg-coral-dark">
Book now
</button>That is a complete UI decision. Layout, color, type, hover — all in the class string. If your sales report, marketing hero, or gallery slider is a bunch of rounded-2xl border border-white/10 on plain <section> and <table> tags, you are using Tailwind and nothing else. That is not a lesser choice. It is the default.
What shadcn Is
shadcn is not an npm package you import like @mui/material. The CLI copies React source into your tree. You own those files. You can change the radius, swap the accent, delete a variant. Upgrades are git diffs, not a major-version bump behind a lockfile.
A typical copied file is not an HTML snippet. It is a thin wrapper: take a primitive, attach a long Tailwind className, export a nicer name.
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
// …then DropdownMenuContent, Item, etc. with className={cn("z-50 min-w-[8rem] …")}Those first lines are not markup. They re-export Radix. The Tailwind shows up on Content and Item. If you only remember one thing from this post:the file you copied is a skin. The behavior often lives in node_modules.
What You Do as a Developer
Your job is ordinary product work. The CLI is a shortcut for the first draft of a widget, not a second styling system you have to "learn instead of Tailwind."
The loop you actually run
- Init once: Tailwind,
globals.cssvariables, acn()helper,components.jsonso the CLI knows where to put files. - Add a widget when you need one:
npx shadcn add buttonordropdown-menu. That writes source intocomponents/uiand installs missing packages. - Compose it in a page:
<Button variant="outline" size="sm">instead of a 40-class raw button. - Restyle by editing the copied file or passing
className. You are still writing Tailwind — just in one place. - Skip the CLI when a one-off is cheaper. A homepage carousel that is
translateXplus dots does not need a shadcn Carousel. A KPI card does not need a Card primitive. Custom markup plus utilities is the same stack.
The skill that matters is not "knowing shadcn." It is knowing which job you are doing: painting a one-off surface, or reusing a widget whose keyboard and accessibility behavior you do not want to reimplement.
What Happens Behind the Scenes
When you add a component, two different things land in the project. Mixing them up is why the stack feels magical.
components/ui/dropdown-menu.tsx— the skin- CSS variables in
globals.css(--primary,--radius) lib/utils.tscn()if it was not already there- Tailwind config tokens that map names like
bg-primaryto those variables
@radix-ui/react-dropdown-menu— open state, focus, portal, ARIAclass-variance-authority—variant/sizemapslucide-react— SVG iconsclsx+tailwind-merge— class merging
At runtime the browser does not know shadcn exists. It sees DOM that Radix created (or a native <button>), plus a stylesheet Tailwind generated from the class names in your source. The CLI never ships a shadcn runtime.
Build time is equally ordinary. Tailwind's content glob includes components/**, so classes inside the copied files survive purge. Change bg-primary to bg-coral in the Button file and the next build emits coral. There is no theme provider required for that — though many apps also keep HSL variables so light/dark is a CSS swap.
npx shadcn add dropdown-menu
Your repo
dropdown-menu.tsx ← you own this (Tailwind skin + re-exports)
globals.css vars ← theme tokens, if not already there
lib/utils.ts cn() ← if not already there
node_modules
@radix-ui/react-dropdown-menu ← behavior you did not write
lucide-react ← iconsThe Pieces in the Box
Every shadcn widget is a small assembly. Once you can name the parts, the copied file stops looking like a black box.
Tailwind — the paint
The long className="flex rounded-md bg-popover shadow-md …" string is the look. Hover, focus rings, dark surfaces, animation — all utilities. If you can read Tailwind, you can restyle any shadcn component without learning a second API.
Radix — the behavior
Radix UI is a set of headless React primitives: they handle behavior and accessibility, and they draw almost nothing. A <div> you show and hide with useState looks like a menu. It is missing a lot:
- Open/close state and click-outside
- Focus trap and restore focus to the trigger
- Escape to close
- Arrow-key movement and typeahead
- A portal so
overflow: hiddenon a parent does not clip the panel - Flipping above the button when there is no room below
- ARIA:
aria-expanded,role="menu",aria-haspopup - Scroll lock on dialogs
Radix implements that. It gives you Root, Trigger, Portal, Content, Item. They render real DOM with almost no CSS. That is why shadcn exists: Radix is the brain, Tailwind is the clothes.
A button barely needs Radix. A native <button> already has click, focus, and Enter/Space. A dropdown, dialog, select, tooltip, or accordion needs a lot of behavior. That is where the npm primitive does the real work.
Radix also stamps data attributes onto the DOM — data-state="open", data-side="bottom", data-state="checked". Tailwind hooks those:
data-[state=open]:animate-in
data-[state=checked]:bg-primary
data-[side=bottom]:slide-in-from-top-2Those classes do nothing until Radix sets the attribute. Paint and behavior meet at that attribute. If a style "does not apply," check whether the primitive is actually in the state you styled for.
CVA — named looks
class-variance-authority is not behavior. It is a map: variant="outline" plus size="sm" becomes one Tailwind string. That map lives in the file you copied. Changing outline to use a coral border is a one-line edit in buttonVariants, not a theme JSON file in a locked package.
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium …",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
},
size: {
default: "h-9 px-4",
sm: "h-8 px-3 text-xs",
},
},
}
);Slot / asChild — the same styles on a different tag
@radix-ui/react-slot is the main Radix use on Button. asChild means: do not render your own <button>; merge these classes and props onto my child.
<Button asChild>
<Link href="/gallery">See gallery</Link>
</Button>You get button styles on an <a> without wrapping a link in a button (invalid HTML). Slot copies className, onClick, and ref onto Link.
cn() — merging without fights
Almost every file imports cn from lib/utils. It is clsx plus tailwind-merge. You pass className="mt-4" and it does not leave a leftover mt-2 from the component defaults. Without the merge step, two margin utilities can both survive and the wrong one wins in the stylesheet, not in source order you expect.
Lucide — icons, not structure
Chevrons, checks, and X marks are SVG components from lucide-react. They are decorative. They are not the menu.
CSS variables — the theme
shadcn classes say bg-primary and text-popover-foreground. The hex (or HSL) lives in :root and .dark as --primary, --popover-foreground, --radius. Tailwind config maps those names. Rebranding a whole kit is often a variable change, not a hunt through twenty components — unless someone already hardcoded bg-coral in a local fork, which production apps do all the time.
"Only shadcn" Is Not a Third Option
You cannot drop Tailwind and keep shadcn in the usual setup. The components are Tailwind-styled React. If a teammate says "we only use shadcn," they usually mean one of two things:
- They add CLI components and rarely write one-off utilities on the page.
- They think shadcn is a closed library like MUI. It is not. Open
components/ui/button.tsx— that is your code.
A checkbox makes the split obvious. Stock shadcn checkbox is not <input type="checkbox">. It is a Radix control that manages checked state and shows a Lucide check. You can also ship a native input with Tailwind and skip Radix entirely. Same idea of a checkbox; different behavior layer. Both are valid. Only one came from npx shadcn add checkbox.
<CheckboxPrimitive.Root
className="h-4 w-4 rounded-sm border
data-[state=checked]:bg-primary"
>
<CheckboxPrimitive.Indicator>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root><input
type="checkbox"
className="h-4 w-4 rounded
border-white/30 accent-coral"
/>How to Read Any UI File
Open a component and ask two questions. The answers tell you what stack you are looking at — including files that never came from the CLI.
1. Who drew the pixels?
Almost always Tailwind classes — in the JSX, in a *.styles.ts object of class strings, or in a CVA variant map. If you see sx={{ color: 'primary.main' }} or a CSS module, you have left this stack.
2. Who owns the widget?
- You, as one-off markup → just Tailwind.
- A file under
components/uithat imports@radix-ui/…→ Tailwind plus a shadcn-style skin. - A behavior library with no look (Radix or Embla used directly) → unstyled primitive; someone still has to add Tailwind.
Inside a shadcn file, read it in this order:
- The
import * as X from "@radix-ui/…"— the behavior engine. - The long
className={cn("flex rounded-md …")}— Tailwind. cva({ variants: … })— named looks.data-[state=open]:/data-[side=bottom]:— Tailwind hooks on states Radix sets.
If the component is simple (Button, Badge), you mostly copied a styled template. If it is a menu, dialog, or select, you copied a styled template and installed a behavior engine. Tailwind never did the keyboard or accessibility work. Radix did.
A Practical Mix Is the Healthy Default
Real apps do not pick one column and stay there. A consumer site we run paints admin reports and a homepage gallery with raw Tailwind and homemade React state — translateX, a timer, arrow buttons. The same repo has a shadcn-style Button (CVA + Slot) and uses Radix dropdown primitives for the account menu. That is not inconsistency. It is matching the tool to the job.
- One-off marketing or dashboard layout → Tailwind on your markup.
- Reusable button with variants → copied Button, still Tailwind.
- Menu, dialog, combobox → copied skin over Radix. Do not reimplement focus.
- Novel motion (a custom carousel) → your state machine plus utilities. A prefab is optional, not required.
Takeaway
Treat shadcn as "a Button, Dialog, or Select we copied into the repo, styled with Tailwind, often sitting on Radix." It is not a second styling system next to Tailwind, and it is not a reason to stop writing utilities. Once that sentence is clear, the components/ui folder stops being mysterious — it is just React you already know how to edit.