# shadcn Tailwind v4 Migration: Step by Step From v3

> A shadcn tailwind v4 migration in order: the upgrade codemod, tailwind.config into @theme inline, hsl to oklch, border and ring fixes, and tw-animate-css.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/shadcn-tailwind-v4-migration

## TL;DR

Migrate a shadcn project to Tailwind v4 on a branch: run npx @tailwindcss/upgrade, move :root and .dark out of @layer base with hsl() around each value, map them in @theme inline, then delete tailwind.config.js and leave the config field empty in components.json. Swap tailwindcss-animate for tw-animate-css, and check by hand what the codemod cannot see: bare border and ring classes, class names built at runtime, chart colors and accordion keyframes.

You have a working shadcn/ui app on Tailwind v3, and new shadcn components and most current tutorials now assume Tailwind v4. A shadcn tailwind v4 migration is mostly mechanical, but the parts that are not mechanical fail quietly: borders turn black, focus rings get thinner, accordions stop animating, and a color that came from `tailwind.config.js` simply does nothing. This guide is the order we would follow on a real project, with the CSS before and after each step and a list of the components that need a hand fix. We build Wingo UI, a React 19 and Tailwind v4 component library, so outside facts link to their sources, checked on November 6, 2026.

## Should you migrate a shadcn project to Tailwind v4 now?

Yes, if your users are on current browsers. Tailwind v4 needs Safari 16.4, Chrome 111 or Firefox 128 and newer, according to the [Tailwind upgrade guide](https://tailwindcss.com/docs/upgrade-guide). If you must support older browsers, stay on v3.4. The upgrade tool also needs Node.js 20 or newer.

There is no deadline. The [shadcn Tailwind v4 page](https://ui.shadcn.com/docs/tailwind-v4) says the change is non-breaking: existing v3 and React 18 apps keep working, and components you add stay on v3 until you upgrade. The cost of waiting is that every new component and example you copy is written for v4.

Do the work on its own branch, with no feature changes mixed in, so the diff only shows migration.

## What are the Tailwind v4 breaking changes that hit shadcn?

The [upgrade guide](https://tailwindcss.com/docs/upgrade-guide) lists about twenty. These are the ones a shadcn project runs into:

| Change | v3 | v4 | Where it shows up |
| --- | --- | --- | --- |
| Configuration | `tailwind.config.js` | CSS: `@theme`, `@plugin`, `@utility` | every color and radius token |
| Entry point | `@tailwind base; components; utilities` | `@import "tailwindcss";` | `globals.css` |
| PostCSS plugin | `tailwindcss` + `autoprefixer` | `@tailwindcss/postcss` | `postcss.config.mjs` |
| Default border color | `gray-200` | `currentColor` | any bare `border` class |
| Bare `ring` | 3px, `blue-500` | 1px, `currentColor` | custom focus styles |
| `outline-none` | invisible outline | `outline-style: none`; old behavior is `outline-hidden` | buttons, inputs, triggers |
| Scale renames | `shadow-sm`, `rounded-sm`, `blur-sm` | `shadow-xs`, `rounded-xs`, `blur-xs` | cards, popovers, inputs |
| CSS variable shorthand | `w-[--sidebar-width]` | `w-(--sidebar-width)` | sidebar, custom widths |
| `hover:` | always | only on devices that can hover | anything that relied on sticky hover on phones |
| Button cursor | `pointer` | `default` | every `<button>` |
| Custom utilities | `@layer utilities` | `@utility name { ... }` | your own helpers |

The hover change is a good one for mobile: a tap no longer leaves a button stuck in its hover color. The cursor change is the one people notice first.

## Step 1: How do you run the upgrade codemod?

Commit everything, then run the official tool from the project root:

```bash
git switch -c chore/tailwind-v4
npx @tailwindcss/upgrade
```

The tool updates `package.json`, replaces the three `@tailwind` directives with `@import "tailwindcss";`, moves the PostCSS setup to `@tailwindcss/postcss`, converts your JavaScript config into CSS, and renames utilities in your source files (`shadow-sm` to `shadow-xs`, `outline-none` to `outline-hidden`, `w-[--x]` to `w-(--x)`). The shadcn Tailwind v4 page still shows the command with `@next`, a leftover from the v4 beta; the current Tailwind guide drops the tag.

Two limits matter. The tool can only rewrite class names it can read as full strings, so anything built at runtime (`` `bg-${tone}-500` ``) stays as it was. And it maps your tokens with `hsl(var(--x))` wrappers inside `@theme`, which works but is awkward. Step 2 cleans that up. If the tool stops with "Could not load the configuration file", an error reported in the [shadcn upgrade discussion](https://github.com/shadcn-ui/ui/discussions/2996), check that the config loads on its own with `node -e "require('./tailwind.config.js')"` and fix what that prints before you run the tool again.

Then start the dev server and look at the app before you change anything else.

## Step 2: How do you move tailwind.config into @theme?

This is the heart of the Tailwind v3 to v4 migration. A v3 shadcn project keeps the raw values in `globals.css` and the mapping to utilities in `tailwind.config.js`:

```js
// tailwind.config.js (v3)
module.exports = {
  darkMode: ["class"],
  theme: {
    extend: {
      colors: {
        border: "hsl(var(--border))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
};
```

In v4 there is no separate Tailwind v4 config file: both halves live in CSS. The shadcn docs give four edits on top of the codemod output: move `:root` and `.dark` out of `@layer base`, wrap each value in `hsl()`, change `@theme` to `@theme inline`, and drop the `hsl()` wrappers inside it. The result:

```css
/* app/globals.css (v4) */
@import "tailwindcss";
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));

:root {
  --radius: 0.5rem;
  --background: hsl(0 0% 100%);
  --foreground: hsl(222.2 84% 4.9%);
  --primary: hsl(222.2 47.4% 11.2%);
  --primary-foreground: hsl(210 40% 98%);
  --border: hsl(214.3 31.8% 91.4%);
  --ring: hsl(222.2 84% 4.9%);
}

.dark {
  --background: hsl(222.2 84% 4.9%);
  --foreground: hsl(210 40% 98%);
  --primary: hsl(210 40% 98%);
  --primary-foreground: hsl(222.2 47.4% 11.2%);
  --border: hsl(217.2 32.6% 17.5%);
  --ring: hsl(212.7 26.8% 83.9%);
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-border: var(--border);
  --color-ring: var(--ring);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}
```

Why `inline` matters: with plain `@theme`, `bg-background` compiles to `var(--color-background)`, and that variable is resolved where it is defined, on `:root`. A `.dark` class below the root, or a theme scoped to one section, never changes it. With `@theme inline`, Tailwind writes `var(--background)` into the utility itself, so the value resolves on the element that uses it. A commenter in the shadcn upgrade discussion reported on December 28, 2024 that dark mode did not work for them until they switched to `@theme inline`. The [Tailwind v4 dark mode guide](https://wingo-ui.com/blog/tailwind-v4-dark-mode) covers the class variant and next-themes in detail.

`darkMode: ["class"]` becomes the `@custom-variant` line. The `* { @apply border-border; }` rule only compiles once `--color-border` exists in `@theme`; "Cannot apply unknown utility class: border-border" means the mapping is missing or a leftover config is still loaded.

When the page looks right, delete `tailwind.config.js` and the `@config` line the codemod added, then set the config path empty in `components.json`, as the [components.json docs](https://ui.shadcn.com/docs/components-json) say for v4:

```json
{
  "tailwind": {
    "config": "",
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  }
}
```

## Step 3: Should you convert hsl tokens to oklch?

Only if you want to. Once each variable holds a full color, `hsl()` works fine in v4, and the format does not change contrast. shadcn's current palette is oklch (the neutral theme uses `--background: oklch(1 0 0)` and `--foreground: oklch(0.145 0 0)` per the [theming docs](https://ui.shadcn.com/docs/theming)), and its dark colors were revised for accessibility on March 12, 2025. If you want those values, copy them from the theming page instead of converting yours, and keep your brand color as it is.

Two things do need changing now that variables hold complete colors:

```ts
// chart config: the variable is already a color
const chartConfig = {
  desktop: { label: "Desktop", color: "var(--chart-1)" }, // was "hsl(var(--chart-1))"
  mobile: { label: "Mobile", color: "var(--chart-2)" },
};
```

Also search your code for `hsl(var(--` in inline styles, SVG fills and `color-mix()` calls. Each one now wraps a color in a second `hsl()` and renders nothing. A shadcn Tailwind v4 theme whose tokens are full colors is also easier to extend: `color-mix(in oklab, var(--primary) 10%, transparent)` works anywhere. Our [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens) guide goes further, with surfaces, status tones and a contrast script.

## Step 4: How do you fix borders and focus rings?

Search for every bare `border`, `border-t`, `divide-y` and `ring` without a color. The base rule from step 2 fixes borders globally, which is why most shadcn components look right after the upgrade. Anything rendered outside your stylesheet's reach (a portal into a third-party root, an iframe) still draws `currentColor` borders.

Rings are the subtle one. Stock shadcn v3 components use `ring-2`, which keeps its width, but custom code with a bare `ring` drops from 3px to 1px, and a 1px ring often fails as a focus indicator. Write the width and color explicitly:

```tsx
// before: 3px blue in v3, 1px currentColor in v4
<button className="focus-visible:ring">Save</button>

// after: explicit in both
<button className="outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/50">Save</button>
```

For the cursor, add the snippet from the upgrade guide if you want pointers back on every button:

```css
@layer base {
  button:not(:disabled),
  [role="button"]:not(:disabled) {
    cursor: pointer;
  }
}
```

Then tab through every page with the keyboard, in both themes. That one pass catches most ring and outline regressions.

## Step 5: What replaces tailwindcss-animate?

`tw-animate-css`. shadcn deprecated `tailwindcss-animate` on March 19, 2025, and new projects get the replacement by default:

```bash
npm uninstall tailwindcss-animate
npm install -D tw-animate-css
```

Remove `@plugin "tailwindcss-animate";` from `globals.css` and add `@import "tw-animate-css";` after the Tailwind import. The classes your dialogs, popovers and dropdowns use (`animate-in`, `fade-in-0`, `zoom-in-95`, `slide-in-from-top-2`) keep working, and the package ships `animate-accordion-down` and `animate-accordion-up`, so the keyframes you had in `tailwind.config.js` can go. Its [README](https://github.com/Wombosvideo/tw-animate-css) warns that it is not a 100% compatible drop-in, so click through every animated component once.

Another option is to animate in JavaScript. Our components use `motion` springs instead of keyframes: the [Accordion](https://wingo-ui.com/components/accordion) springs its height open and fades the answer in, and with reduced motion on it skips the height animation and only fades. There is no animation plugin to migrate at all.

> Live demo (Accordion): Open a row, then open another: the height springs and the answer fades in before the height settles. Turn on reduced motion in your system settings to see the answer fade in place instead. Try it at [Accordion](https://wingo-ui.com/components/accordion) and install it with `npx wingo-ui@latest add accordion`.

## Which shadcn components need a manual fix?

The codemod handles most class strings. These are the places it misses or where the defaults change the look:

- **Sidebar**: it sizes itself with classes such as `w-[--sidebar-width]`, which v4 writes as `w-(--sidebar-width)`. Check that the codemod caught every one and that the sidebar still opens at its full width. A commenter whose sidebar broke after the upgrade fixed it on February 3, 2025 by taking `sidebar.tsx` and `globals.css` from shadcn's Tailwind v4 example app.
- **Radius classes**: the codemod renames `rounded-sm` to `rounded-xs` and `rounded` to `rounded-sm`. Your theme maps `--radius-sm` itself, so the rename can change the look: `rounded-sm` used to mean `--radius` minus 4px, and `rounded-xs` falls back to Tailwind's default 2px. Compare a few inputs and badges with the v3 build and keep whichever class gives the radius you had.
- **Accordion and Collapsible**: they lose their animation if you removed the config keyframes before adding `tw-animate-css`.
- **Chart**: `hsl(var(--chart-n))` in `chartConfig`, covered in step 3.
- **Toast**: deprecated in favor of Sonner. Your existing toast keeps running, but shadcn now points new projects to `sonner`.
- **Anything with `cva` variants built from template strings**: the tool cannot read them. Grep for `` `${ `` inside `className`.
- **Components you edited**: `npx shadcn@latest add --all --overwrite` rewrites every file with the v4 version. Commit first, then reapply your changes from the diff. Our post on [updating shadcn components without losing edits](https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits) explains why a three-way merge saves this step.

The rest of shadcn's v4 changes are optional: `size-4` instead of `w-4 h-4`, `data-slot` on every part, and plain functions instead of `forwardRef` (React 19 passes `ref` as a prop; see our [React 19 forwardRef migration](https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop)).

## What does a component written for Tailwind v4 look like?

It paints through CSS variables, uses v4 variants instead of JavaScript for touch and focus, and needs no config file. Our [Button](https://wingo-ui.com/components/button) is a fair example: variants paint through CSS variables with the new shorthand (`bg-(--tone)`, `text-(--tone-foreground)`), focus uses `outline-hidden` plus a 2px `outline-ring`, and the `pointer-coarse:` variant grows the default size to 44px on touch screens with no JavaScript. The `--tone-*` variables come from [Tones](https://wingo-ui.com/components/tones), so `tone="success"` or `color="#0EA5E9"` changes every variant without new classes:

```tsx
import { Button } from "@/components/ui/button";

export function SaveBar() {
  return (
    <div className="flex gap-2">
      <Button variant="outline">Cancel</Button>
      <Button tone="brand" loading loadingText="Saving">
        Save changes
      </Button>
    </div>
  );
}
```

> Live demo (Button): Tab to a button to see the 2px focus outline, press it to feel the spring, then switch the site theme: the fills, tints and outline all come from the tokens. Try it at [Button](https://wingo-ui.com/components/button) and install it with `npx wingo-ui@latest add button`.

App-wide defaults go through [UIProvider](https://wingo-ui.com/components/ui-config), so a migrated app can set `radius` or `size` once instead of editing every file:

```tsx
"use client";

import type { ReactNode } from "react";
import { UIProvider } from "@/lib/ui-config";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <UIProvider defaults={{ button: { radius: "full" }, card: { variant: "outline" } }}>
      {children}
    </UIProvider>
  );
}
```

If you add our components to the migrated project, run `npx wingo-ui@latest init` after the migration, not before. It reads the stylesheet path from `components.json`, warns when that file is still a v3 stylesheet without `@import "tailwindcss"`, and adds only the tokens that are missing: values your shadcn theme already defines stay as they are. Then `npx wingo-ui@latest add button card` drops in the [Card](https://wingo-ui.com/components/card) and Button source. More guides on this stack are in [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind).

## How do you check that the migration worked?

Run through this list before you merge:

1. `npm run build` passes and the dev server shows no "unknown utility" errors.
2. Every page in light and dark: no black borders, no missing backgrounds.
3. Keyboard pass: every control shows a visible focus ring.
4. Open every dialog, sheet, popover, dropdown and accordion: each one still animates.
5. Charts show their colors.
6. On a phone: buttons do not stay tinted after a tap, and the sidebar opens at full width.
7. `git grep -n "hsl(var(--"` and `git grep -n "tailwind.config"` return nothing you still need.

## What should you do next?

Run the codemod on a branch today and fix what the checklist finds; for a typical shadcn app the hand fixes are a few small commits. If you want components that were written for Tailwind v4 from the start, with tokens, focus rings and touch sizes already done, add Button, Card and Accordion with a free account (email only, no card): they are among the 75 free items in the 357-item catalog, and the [pricing page](https://wingo-ui.com/pricing) shows what Pro adds.

## Components in this post

- [Button](https://wingo-ui.com/components/button) (Free): The React button component every screen starts with: five variants, six tones, all sizes, and a loading state that never jumps. Install: `npx wingo-ui@latest add button`
- [UIProvider](https://wingo-ui.com/components/ui-config) (Free): A React config provider for Wingo UI: app-wide default props for every component, the Intl locale and a reduced-motion policy. Install: `npx wingo-ui@latest add ui-config`
- [Tones](https://wingo-ui.com/components/tones) (Free): One way to color a React component: a tone or any CSS color becomes five CSS variables every variant paints with. Install: `npx wingo-ui@latest add tones`
- [Card](https://wingo-ui.com/components/card) (Free): A React card component for listings, stats, settings and plans: media, header, body and footer parts, and a lift-and-press mode. Install: `npx wingo-ui@latest add card`
- [Accordion](https://wingo-ui.com/components/accordion) (Free): A React accordion component for FAQs, settings sections and order details, with four variants, icons, descriptions and one or many open. Install: `npx wingo-ui@latest add accordion`

## FAQ

### Does shadcn/ui support Tailwind v4?

Yes. The shadcn CLI sets up new projects on Tailwind v4 and React 19, and every component was updated with data-slot attributes and oklch colors. Existing Tailwind v3 projects keep working, and the shadcn docs call the upgrade non-breaking.

### Do I still need a tailwind.config file with Tailwind v4?

No. Tailwind v4 reads its configuration from CSS: theme values go in @theme, plugins in @plugin, custom utilities in @utility. A JavaScript config still works if you load it with @config, but it is no longer detected automatically, and shadcn's components.json expects the tailwind.config field to be empty on v4.

### Why are my borders black after upgrading to Tailwind v4?

Tailwind v4 changed the default border color from gray-200 to currentColor, so a bare border class takes the text color. shadcn projects fix it with a base rule, * { @apply border-border; }, which needs --color-border mapped in @theme inline to work.

### What replaces tailwindcss-animate in Tailwind v4?

tw-animate-css, which shadcn adopted on March 19, 2025. Install it as a dev dependency, remove @plugin "tailwindcss-animate", and add @import "tw-animate-css" after @import "tailwindcss". It covers animate-in, fade, zoom, slide and the accordion and collapsible keyframes, though its README warns it is not a 100% drop-in.

### Do I have to convert my shadcn colors from hsl to oklch?

No. hsl() values work in Tailwind v4 as long as each variable holds a complete color. Convert to oklch only if you want shadcn's current palette or easier lightness tweaks, and run a contrast check after you change any value.

---

Source: https://wingo-ui.com/blog/shadcn-tailwind-v4-migration
