shadcn/ui on Tailwind v4: Three Jobs, Only One of Them shadcn's
Tokens into @theme, the dark variant, then the components. Compiled selectors measured in a v4 repo: why :is() and :where() change your cascade.
Moving shadcn/ui onto Tailwind v4 is three separate jobs: migrating your tokens into @theme, re-pointing the dark variant, and updating the components themselves. Only the third is shadcn-specific, and the first two are where builds break quietly. Here is the CSS half measured in a production v4 repo, plus shadcn's own documented component steps.
A note on provenance, because it changes how you should read this. This storefront runs Tailwind v4 CSS-first — tailwindcss 4.3.1, no tailwind.config.js, 41 tokens in one @theme block — but it does not use shadcn/ui. So every number and compiled selector below was measured here, and every shadcn-specific step is cited from shadcn/ui's own tailwind-v4 documentation rather than from experience installing it. The CSS and tooling halves are identical either way, which is the useful part: they are plain Tailwind v4 problems wearing a shadcn label.
The three jobs
| Job | Who owns it | What breaks if you skip it |
|---|---|---|
Tokens into @theme | Tailwind v4 | Colour utilities stop generating; bg-background silently emits nothing |
The dark variant | Your CSS | Dark mode either never activates or outranks your overrides |
Components (forwardRef, data-slot, size-*) | shadcn/ui | Nothing breaks immediately — you drift from the registry |
The order matters. Jobs 1 and 2 are prerequisites: run shadcn's component codemod first and you will be debugging component diffs while your theme layer is still broken.
Job 1: tokens move into @theme
In v3 your tokens lived in JavaScript. In v4 they live in CSS, and that is the whole config story — this repo's src/app/globals.css opens with four lines and never mentions a config file:
@import "tailwindcss";
/* Class-based dark mode (toggled by adding `dark` to <html>) */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
--color-primary: #465fff;
--color-primary-500: #465fff;
--color-primary-600: #3641f5;
--color-gray-800: #1d2939;
--color-gray-900: #101828;
--breakpoint-2xsm: 375px;
}
The build pipeline is one PostCSS plugin, and that is the entire toolchain:
// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Why shadcn needs @theme inline and this repo does not
This is the single most confusing part of the migration, and it comes down to one indirection.
This repo declares its colours directly as theme variables: --color-primary: #465fff is both the token and the value. One hop. A plain @theme block is correct.
shadcn keeps a semantic layer: --background is defined on :root, redefined on .dark, and --color-background points at it. Two hops. shadcn's docs describe what their codemod produces and then what to change it to:
/* What the codemod leaves you with */
@layer base {
:root {
--background: 0 0% 100%;
}
}
@theme {
--color-background: hsl(var(--background));
}
Their documented fix is four steps: move :root and .dark out of @layer base, wrap the values in hsl(), add the inline option, and drop the hsl() wrappers from the theme block:
:root {
--background: hsl(0 0% 100%);
}
.dark {
--background: hsl(0 0% 3.9%);
}
@theme inline {
--color-background: var(--background);
}
The inline keyword is what makes the override work. Without it, --color-background resolves var(--background) once, where the theme is declared — so .dark redefining --background later changes nothing, and dark mode appears to do nothing at all. With inline, the reference is substituted at each use site, so the value is resolved against whichever scope the element is in. If your tokens have no semantic indirection, as here, you never meet this problem.
shadcn also notes the knock-on effect: because the theme colours now carry their own hsl(), chart configs drop the wrapper — color: "hsl(var(--chart-1))" becomes color: "var(--chart-1)".
Job 2: the dark variant, and the one line that differs
Both projects toggle dark mode with a class, and both write one @custom-variant line. The lines are not the same:
| Selector | Source | |
|---|---|---|
| This repo | @custom-variant dark (&:where(.dark, .dark *)); | src/app/globals.css |
| shadcn/ui | @custom-variant dark (&:is(.dark *)); | shadcn's own apps/v4/app/globals.css, and the globals.css their init fixture produces |
To see what each compiles to, both were run through this repo's own installed Tailwind, same input, variant line swapped:
ours (&:where(.dark, .dark *)) -> .dark\:bg-black { &:where(.dark, .dark *) { … } }
shadcn (&:is(.dark *)) -> .dark\:bg-black { &:is(.dark *) { … } }
Flattened in the production build, this repo's 74 dark rules look like this:
.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}
Two real differences follow, and neither shows up in a first test.
Scope. :is(.dark *) is a descendant selector — it matches elements inside .dark, never the .dark element itself. :where(.dark, .dark *) lists both, so it also matches the element carrying the class. With .dark on <html> and utilities on descendants, the two behave identically, which is why this almost never surfaces. Put class="dark" and a dark: utility on the same element and only the second form applies.
Specificity. :where() always has zero specificity. :is() takes the specificity of its most specific argument, so .dark * contributes a class. shadcn's dark: utilities therefore sit one class higher than a plain utility, and this repo's sit level with one:
| Variant | Selector specificity | Consequence |
|---|---|---|
&:where(.dark, .dark *) | 0,1,0 — same as any utility | Source order decides; a later utility can override |
&:is(.dark *) | 0,2,0 — one class higher | The dark: utility wins regardless of order |
For class="bg-white dark:bg-black" both are fine. The difference bites in a component that merges caller classes — cn("dark:bg-black", className) with a caller passing bg-red-500. Under shadcn's variant the dark utility outranks the caller's override in dark mode and the override silently fails. This is a large part of why shadcn ships tailwind-merge: it resolves conflicting classes in the class string, before the CSS cascade ever gets a vote. If you adopt shadcn's components but keep a :where-based variant, you have changed the cascade their components were written against.
Pick one line and use it everywhere. Mixing them across files is how you get dark mode that works on one page and not another.
Job 3: the components
This is the part only shadcn owns, and because the code lives in your repo rather than in node_modules, it is yours to migrate. shadcn's documented path:
- Run the Tailwind upgrade first — their docs point at Tailwind's official upgrade guide and the
@tailwindcss/upgrade@nextcodemod. - Upgrade the runtime deps — their published command is
pnpm up "@radix-ui/*" cmdk lucide-react recharts tailwind-merge clsx --latest. - Remove
forwardRef— React 19 lets a function component takerefas a prop.React.forwardRef<…>becomesReact.ComponentProps<…>,ref={ref}goes, anddisplayNamecan go with it. - Add
data-slot— every primitive gets one, which is what gives you a styling handle on an inner part without aclassNameprop for each. - Swap
w-4 h-4forsize-4—tailwind-mergenow understandssize-*. - Replace the animation plugin —
tailwindcss-animateis deprecated in favour oftw-animate-css: drop@plugin 'tailwindcss-animate'and add@import "tw-animate-css".
shadcn's docs are explicit that this is non-breaking: existing v3 + React 18 apps keep working, and only new projects start on v4 and React 19. They also note HSL colours are now converted to OKLCH, and that toast is deprecated in favour of sonner.
The toolchain step nobody mentions
Prettier's Tailwind plugin sorts classes by reading your theme. In v3 it found a config file by itself. In v4 there is no config file to find, so you have to point it at the stylesheet — this repo's entire .prettierrc.json:
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app/globals.css"
}
Miss this and nothing errors. The plugin falls back to Tailwind's default theme, so your custom tokens sort as unknown classes and every file you touch produces class-order churn in the diff. The key is tailwindStylesheet, not the v3 tailwindConfig.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
New @theme colour generates no utility in dev | Tailwind v4 + Turbopack does not hot-reload @theme | Restart npm run dev — this repo's CLAUDE.md carries a standing warning about it |
.dark overrides do nothing | Semantic tokens under a plain @theme, resolved once at declaration | Use @theme inline so var() resolves per use site |
| Dark mode works on children but not the toggled element | &:is(.dark *) is descendant-only | Use &:where(.dark, .dark *) if you style the element that carries the class |
A caller's className loses to a dark: class | :is() adds a class of specificity | Merge classes with tailwind-merge, or move to a :where() variant |
| Class order churns on every save | Prettier plugin cannot find the theme | Set tailwindStylesheet in .prettierrc.json |
| Utility missing from the build entirely | Class name assembled by string concatenation | Write complete class names in source — the scanner reads text, not runtime values |
Frequently asked questions
Do I need shadcn/ui to use Tailwind v4 this way?
No. The @theme block, the @custom-variant dark line and the PostCSS plugin are plain Tailwind v4. This repo ships 852 static pages, 51 components using dark: 498 times, and an 85,025-byte stylesheet (15,103 gzipped) with no component library at all.
Is @theme or @theme inline right for my project?
@theme if your tokens hold values directly. @theme inline if a token points at another variable you redefine in a scope such as .dark — which is shadcn's structure. Choosing wrong in that direction is exactly the "dark mode does nothing" bug.
Can I upgrade Tailwind without touching my shadcn components? Yes. shadcn's docs state the change is non-breaking for existing v3 and React 18 projects. Jobs 1 and 2 are the ones your build actually depends on; the component migration can follow later, component by component.
Where did the tailwind.config.js content go?
Into @theme in your CSS. There is no config file in this repo — see Tailwind v4 config for the full mapping, and Tailwind v4 migration for the upgrade order.
Templates in this post
Each of these ships on the v4 CSS-first setup described above — tokens in @theme, one @custom-variant dark line, no config file: ASoc Nexus, ASoc Nimbus and ASoc Nova.
Browse the sets: Tailwind landing page templates and Next.js landing page templates. If you are still deciding whether shadcn and Tailwind are even alternatives to each other, read shadcn vs. Tailwind next.
