Tailwind CSS v4
How to write idiomatic Tailwind v4 and spot v3-era syntax that still compiles but should not appear in new code.
Version and sources
Check the pinned version before using recent utilities: the playground packages pin tailwindcss in their package.json (4.3.3 at the time of writing, including v4.3 utilities like scrollbar-*, zoom-*, and tab-*). When unsure whether a utility, variant, or directive exists in the pinned version, verify against the docs instead of guessing:
CSS-first configuration
Tailwind v4 is configured in CSS, not JavaScript.
| Use |
Never use (v3-era) |
@import 'tailwindcss' |
@tailwind base/components/utilities |
@theme { --color-x: ...; } for tokens that should generate utilities |
tailwind.config.ts for new work |
@utility name { ... } for custom utilities (works with variants) |
@layer utilities { .name { ... } } |
@custom-variant dark (&:is(.dark *)) |
JS plugins / addVariant |
@source "path" / @source inline("...") for extra sources / safelisting |
content array / safelist config |
@variant dark { ... } to apply a Tailwind variant inside custom CSS |
duplicating media queries / selectors |
@reference "app.css" for @apply in scoped styles (Vue, CSS Modules) |
duplicating stylesheet imports |
var(--color-x) in CSS, getComputedStyle in JS |
theme() function, resolveConfig |
@config "…" / @plugin "…" only for existing JS-config integrations |
adding new JS configs or plugins |
@theme variables are API: each one emits a native CSS variable AND generates utilities (--color-* → bg-*/text-*/border-*/..., --text-* → text-*, --shadow-* → shadow-*, --animate-* → animate-*, --breakpoint-* → responsive variants). A plain :root { --x: ...; } variable generates nothing — use it for runtime-only values. When a token's value references another variable (--color-x: var(--y)), declare it in @theme inline so the utility resolves the reference at the declaration site. In custom CSS, --alpha(var(--color-x) / 50%) and --spacing(4) replace v3 theme() math.
v3 → v4 renames
Bare names shifted one step down the scale, so the v3 spelling silently renders smaller or lighter:
| v3 |
v4 |
shadow-sm / shadow |
shadow-xs / shadow-sm |
drop-shadow-sm / drop-shadow |
drop-shadow-xs / drop-shadow-sm |
blur-sm / blur |
blur-xs / blur-sm |
rounded-sm / rounded |
rounded-xs / rounded-sm |
outline-none |
outline-hidden (a11y-safe); outline-none now truly removes it |
ring (3px) |
ring-3; the default ring is now 1px currentColor |
bg-opacity-50, text-opacity-* |
opacity modifier: bg-black/50, text-white/50 |
bg-gradient-to-r |
bg-linear-to-r (plus new bg-conic-*, bg-radial-*) |
!bg-red-500 (prefix) |
bg-red-500! (suffix) |
flex-shrink-* / flex-grow-* |
shrink-* / grow-* |
bg-[--var] |
bg-(--var); brackets now require bg-[var(--var)] |
grid-cols-[a,b] (commas) |
underscores: grid-cols-[max-content_auto] |
Prefer generated utilities over arbitrary values
The spacing scale is infinite — every number compiles via calc(var(--spacing) * n) — so most v3-era arbitrary values have a named form:
| Don't |
Do |
min-w-[400px], w-[600px] |
min-w-100, w-150 |
h-[1.5rem] w-[1.5rem] |
size-6 |
mt-[68px] |
mt-17 |
grid-cols-[repeat(15,minmax(0,1fr))] |
grid-cols-15 |
h-[100dvh], w-[100dvw], h-[1lh] |
h-dvh, w-dvw, h-lh |
max-w-[80rem] |
max-w-7xl (container scale) |
data-[current]:opacity-100 |
data-current:opacity-100 (values keep brackets: data-[state=open]:) |
bg-[var(--row-bg)] |
bg-(--row-bg); type hints when ambiguous: text-(color:--fg), text-(length:--size) |
Square brackets remain correct for true one-offs: max-h-[calc(100dvh-3rem)], grid-cols-[200px_minmax(0,1fr)], and arbitrary properties like [mask-type:luminance].
Class strings must stay complete and statically detectable: map props to full strings ({ success: 'bg-positive1' }[tone]), never build fragments like `bg-${tone}-500`.
Don't hand-write CSS or JS for what a variant covers
Before writing a stylesheet rule, a style prop, or an event handler for styling, check for a variant:
| Hand-written |
Tailwind |
.btn:active { ... } in CSS |
active:bg-surface5 |
:focus-visible rules |
focus-visible:outline-accent1 |
[aria-expanded="true"] selectors |
aria-expanded:rotate-180 (any aria-* boolean or value) |
[data-state="open"] selectors |
data-[state=open]:opacity-100, boolean: data-current: |
| JS hover/focus state to style a sibling/child |
group/group-hover:, peer/peer-checked:, in-focus: |
| parent-state selectors / JS "has child" checks |
has-checked:bg-accent1, has-[>svg]:pl-8 |
mask-image / fade-out gradients in CSS |
mask-b-from-80%, mask-t-from-* |
@media (hover: none) blocks |
pointer-coarse:/pointer-fine: |
| first/last/nth rules in CSS |
first:, last:, odd:, nth-3:, not-first: |
| styling all children from CSS |
*:rounded-full (children), **:data-avatar:rounded-full (descendants) |
New capabilities — reach for these before hacks or JS
field-sizing-content — auto-growing textarea without a JS resize listener.
wrap-anywhere / wrap-break-word — long-word breaking inside flex without the min-w-0 hack.
items-center-safe, justify-center-safe — centering that falls back to start on overflow.
pointer-coarse: / pointer-fine: — adapt touch targets without user-agent sniffing.
user-valid: / user-invalid: — validation styling only after user interaction (unlike :valid).
starting: (+ transition-discrete for display/popover) — enter transitions without JS mount tricks.
text-shadow-*, mask-t-from-*/mask-b-to-* (fade-out edges), scheme-dark (native controls/scrollbars), 3D transforms (rotate-x-*, perspective-*).
Behavior changes to remember
- Transforms use individual CSS properties: custom transition lists need
transition-[opacity,scale], not transition-[opacity,transform]; reset with scale-none/rotate-none/translate-none.
hover: only applies on hover-capable devices (@media (hover: hover)) — never gate required touch functionality behind hover.
- Default
border-*, divide-*, ring, and outline colors are currentColor — set an explicit color when the color matters.
- Variant stacking applies left to right: v3
first:*:pt-0 is now *:first:pt-0.
space-x/y-* changed selectors (:not(:last-child)); prefer flex/grid with gap-*.
- Container queries are built in:
@container on the parent, @sm:/@max-md: on children.
- Useful v4 variants:
starting: (enter transitions), not-*, in-* (like group-* without the group class), nth-*, *: (direct children), **: (descendants), inert:.
Motion
- Gate decorative animation:
motion-safe:animate-spin motion-reduce:animate-none.
- Reusable animations are
--animate-* tokens with their @keyframes inside @theme; local one-offs use animate-(--local-animation) or animate-[...].
1---2name: tailwind-v43description: Tailwind CSS v4 usage guide and v3-to-v4 differences. This skill should be used when writing, reviewing, or refactoring any Tailwind CSS code in this repo. Triggers on tasks involving Tailwind classes, @theme blocks, CSS-first configuration, or cleanup of v3-era syntax.4---5
6# Tailwind CSS v4
7
8How to write idiomatic Tailwind v4 and spot v3-era syntax that still compiles but should not appear in new code.
9
10## Version and sources
11
12Check the pinned version before using recent utilities: the playground packages pin `tailwindcss` in their `package.json` (4.3.3 at the time of writing, including v4.3 utilities like `scrollbar-*`, `zoom-*`, and `tab-*`). When unsure whether a utility, variant, or directive exists in the pinned version, verify against the docs instead of guessing:
13
14- Utility/variant reference: https://tailwindcss.com/docs
15- v3 → v4 migration: https://tailwindcss.com/docs/upgrade-guide
16- What each minor added: https://tailwindcss.com/blog/tailwindcss-v4 (and `/tailwindcss-v4-1`, `/tailwindcss-v4-3`, ...)
17
18## CSS-first configuration
19
20Tailwind v4 is configured in CSS, not JavaScript.
21
22| Use | Never use (v3-era) |
23| -------------------------------------------------------------------------- | ------------------------------------- |
24| `@import 'tailwindcss'` | `@tailwind base/components/utilities` |
25| `@theme { --color-x: ...; }` for tokens that should generate utilities | `tailwind.config.ts` for new work |
26| `@utility name { ... }` for custom utilities (works with variants) | `@layer utilities { .name { ... } }` |
27| `@custom-variant dark (&:is(.dark *))` | JS `plugins` / `addVariant` |
28| `@source "path"` / `@source inline("...")` for extra sources / safelisting | `content` array / `safelist` config |
29| `@variant dark { ... }` to apply a Tailwind variant inside custom CSS | duplicating media queries / selectors |
30| `@reference "app.css"` for `@apply` in scoped styles (Vue, CSS Modules) | duplicating stylesheet imports |
31| `var(--color-x)` in CSS, `getComputedStyle` in JS | `theme()` function, `resolveConfig` |
32| `@config "…"` / `@plugin "…"` only for existing JS-config integrations | adding new JS configs or plugins |
33
34`@theme` variables are API: each one emits a native CSS variable AND generates utilities (`--color-*` → `bg-*`/`text-*`/`border-*`/..., `--text-*` → `text-*`, `--shadow-*` → `shadow-*`, `--animate-*` → `animate-*`, `--breakpoint-*` → responsive variants). A plain `:root { --x: ...; }` variable generates nothing — use it for runtime-only values. When a token's value references another variable (`--color-x: var(--y)`), declare it in `@theme inline` so the utility resolves the reference at the declaration site. In custom CSS, `--alpha(var(--color-x) / 50%)` and `--spacing(4)` replace v3 `theme()` math.
35
36## v3 → v4 renames
37
38Bare names shifted one step down the scale, so the v3 spelling silently renders smaller or lighter:
39
40| v3 | v4 |
41| --------------------------------- | ----------------------------------------------------------------- |
42| `shadow-sm` / `shadow` | `shadow-xs` / `shadow-sm` |
43| `drop-shadow-sm` / `drop-shadow` | `drop-shadow-xs` / `drop-shadow-sm` |
44| `blur-sm` / `blur` | `blur-xs` / `blur-sm` |
45| `rounded-sm` / `rounded` | `rounded-xs` / `rounded-sm` |
46| `outline-none` | `outline-hidden` (a11y-safe); `outline-none` now truly removes it |
47| `ring` (3px) | `ring-3`; the default ring is now 1px `currentColor` |
48| `bg-opacity-50`, `text-opacity-*` | opacity modifier: `bg-black/50`, `text-white/50` |
49| `bg-gradient-to-r` | `bg-linear-to-r` (plus new `bg-conic-*`, `bg-radial-*`) |
50| `!bg-red-500` (prefix) | `bg-red-500!` (suffix) |
51| `flex-shrink-*` / `flex-grow-*` | `shrink-*` / `grow-*` |
52| `bg-[--var]` | `bg-(--var)`; brackets now require `bg-[var(--var)]` |
53| `grid-cols-[a,b]` (commas) | underscores: `grid-cols-[max-content_auto]` |
54
55## Prefer generated utilities over arbitrary values
56
57The spacing scale is infinite — every number compiles via `calc(var(--spacing) * n)` — so most v3-era arbitrary values have a named form:
58
59| Don't | Do |
60| -------------------------------------- | --------------------------------------------------------------------------------------- |
61| `min-w-[400px]`, `w-[600px]` | `min-w-100`, `w-150` |
62| `h-[1.5rem] w-[1.5rem]` | `size-6` |
63| `mt-[68px]` | `mt-17` |
64| `grid-cols-[repeat(15,minmax(0,1fr))]` | `grid-cols-15` |
65| `h-[100dvh]`, `w-[100dvw]`, `h-[1lh]` | `h-dvh`, `w-dvw`, `h-lh` |
66| `max-w-[80rem]` | `max-w-7xl` (container scale) |
67| `data-[current]:opacity-100` | `data-current:opacity-100` (values keep brackets: `data-[state=open]:`) |
68| `bg-[var(--row-bg)]` | `bg-(--row-bg)`; type hints when ambiguous: `text-(color:--fg)`, `text-(length:--size)` |
69
70Square brackets remain correct for true one-offs: `max-h-[calc(100dvh-3rem)]`, `grid-cols-[200px_minmax(0,1fr)]`, and arbitrary properties like `[mask-type:luminance]`.
71
72Class strings must stay complete and statically detectable: map props to full strings (`{ success: 'bg-positive1' }[tone]`), never build fragments like `` `bg-${tone}-500` ``.
73
74## Don't hand-write CSS or JS for what a variant covers
75
76Before writing a stylesheet rule, a `style` prop, or an event handler for styling, check for a variant:
77
78| Hand-written | Tailwind |
79| ---------------------------------------------- | ------------------------------------------------------------------------ |
80| `.btn:active { ... }` in CSS | `active:bg-surface5` |
81| `:focus-visible` rules | `focus-visible:outline-accent1` |
82| `[aria-expanded="true"]` selectors | `aria-expanded:rotate-180` (any `aria-*` boolean or value) |
83| `[data-state="open"]` selectors | `data-[state=open]:opacity-100`, boolean: `data-current:` |
84| JS hover/focus state to style a sibling/child | `group`/`group-hover:`, `peer`/`peer-checked:`, `in-focus:` |
85| parent-state selectors / JS "has child" checks | `has-checked:bg-accent1`, `has-[>svg]:pl-8` |
86| `mask-image` / fade-out gradients in CSS | `mask-b-from-80%`, `mask-t-from-*` |
87| `@media (hover: none)` blocks | `pointer-coarse:`/`pointer-fine:` |
88| first/last/nth rules in CSS | `first:`, `last:`, `odd:`, `nth-3:`, `not-first:` |
89| styling all children from CSS | `*:rounded-full` (children), `**:data-avatar:rounded-full` (descendants) |
90
91## New capabilities — reach for these before hacks or JS
92
93- `field-sizing-content` — auto-growing textarea without a JS resize listener.
94- `wrap-anywhere` / `wrap-break-word` — long-word breaking inside flex without the `min-w-0` hack.
95- `items-center-safe`, `justify-center-safe` — centering that falls back to `start` on overflow.
96- `pointer-coarse:` / `pointer-fine:` — adapt touch targets without user-agent sniffing.
97- `user-valid:` / `user-invalid:` — validation styling only after user interaction (unlike `:valid`).
98- `starting:` (+ `transition-discrete` for `display`/popover) — enter transitions without JS mount tricks.
99- `text-shadow-*`, `mask-t-from-*`/`mask-b-to-*` (fade-out edges), `scheme-dark` (native controls/scrollbars), 3D transforms (`rotate-x-*`, `perspective-*`).
100
101## Behavior changes to remember
102
103- Transforms use individual CSS properties: custom transition lists need `transition-[opacity,scale]`, not `transition-[opacity,transform]`; reset with `scale-none`/`rotate-none`/`translate-none`.
104- `hover:` only applies on hover-capable devices (`@media (hover: hover)`) — never gate required touch functionality behind hover.
105- Default `border-*`, `divide-*`, ring, and outline colors are `currentColor` — set an explicit color when the color matters.
106- Variant stacking applies left to right: v3 `first:*:pt-0` is now `*:first:pt-0`.
107- `space-x/y-*` changed selectors (`:not(:last-child)`); prefer flex/grid with `gap-*`.
108- Container queries are built in: `@container` on the parent, `@sm:`/`@max-md:` on children.
109- Useful v4 variants: `starting:` (enter transitions), `not-*`, `in-*` (like `group-*` without the `group` class), `nth-*`, `*:` (direct children), `**:` (descendants), `inert:`.
110
111## Motion
112
113- Gate decorative animation: `motion-safe:animate-spin motion-reduce:animate-none`.
114- Reusable animations are `--animate-*` tokens with their `@keyframes` inside `@theme`; local one-offs use `animate-(--local-animation)` or `animate-[...]`.