Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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

JobWho owns itWhat breaks if you skip it
Tokens into @themeTailwind v4Colour utilities stop generating; bg-background silently emits nothing
The dark variantYour CSSDark mode either never activates or outranks your overrides
Components (forwardRef, data-slot, size-*)shadcn/uiNothing 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:

SelectorSource
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:

VariantSelector specificityConsequence
&:where(.dark, .dark *)0,1,0 — same as any utilitySource order decides; a later utility can override
&:is(.dark *)0,2,0 — one class higherThe 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:

  1. Run the Tailwind upgrade first — their docs point at Tailwind's official upgrade guide and the @tailwindcss/upgrade@next codemod.
  2. Upgrade the runtime deps — their published command is pnpm up "@radix-ui/*" cmdk lucide-react recharts tailwind-merge clsx --latest.
  3. Remove forwardRef — React 19 lets a function component take ref as a prop. React.forwardRef<…> becomes React.ComponentProps<…>, ref={ref} goes, and displayName can go with it.
  4. Add data-slot — every primitive gets one, which is what gives you a styling handle on an inner part without a className prop for each.
  5. Swap w-4 h-4 for size-4 — tailwind-merge now understands size-*.
  6. Replace the animation plugin — tailwindcss-animate is deprecated in favour of tw-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

SymptomCauseFix
New @theme colour generates no utility in devTailwind v4 + Turbopack does not hot-reload @themeRestart npm run dev — this repo's CLAUDE.md carries a standing warning about it
.dark overrides do nothingSemantic tokens under a plain @theme, resolved once at declarationUse @theme inline so var() resolves per use site
Dark mode works on children but not the toggled element&:is(.dark *) is descendant-onlyUse &: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 specificityMerge classes with tailwind-merge, or move to a :where() variant
Class order churns on every savePrettier plugin cannot find the themeSet tailwindStylesheet in .prettierrc.json
Utility missing from the build entirelyClass name assembled by string concatenationWrite 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.

Keep reading

Tutorial9 min read

Shopify Advantages, Priced in the Code You Write Without Them

Checkout, order records, apps, the admin: each Shopify advantage measured against the 1,545 lines of TypeScript and 271 of SQL it took to replicate a subset.

Read more
Tutorial8 min read

Shopify International Markets vs One Price and a Merchant of Record

Eight Shopify Markets settings audited against a digital storefront: five are empty, VAT belongs to the MoR, and one currency fallback was printing a dollar sign that lied.

Read more
Tutorial10 min read

Shopping Cart Page Design: The Confirm Step Most Carts Skip

What each region of a cart page has to prove, a two-stage commit lifted from a real redemption picker, and the mobile reorder that needs no second button.

Read more