Skip to content

Commit 33a18fb

Browse files
FreekHeijtingclaude
andcommitted
feat(skill): tailwind-syntax-variants
Syntax skill: all variant families (pseudo-class, pseudo-element, media query, feature query, attribute, combinator, position-in-parent, arbitrary). Includes v3-to-v4 breaking changes: stacking-order flip (L-004), trailing-bang important modifier, hover gated on @media (hover: hover), addVariant() -> @custom-variant migration, v4-only variants (has-*, not-*, in-*, **:, nth-N shortcuts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 523f221 commit 33a18fb

4 files changed

Lines changed: 1184 additions & 0 deletions

File tree

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
---
2+
name: tailwind-syntax-variants
3+
description: >
4+
Use when applying conditional styles in Tailwind: hover, focus, active, dark mode,
5+
responsive breakpoints, group-* and peer-* combinators, aria-* and data-* attribute
6+
variants, has-* and not-* selectors, position-in-parent variants (first, last, *,
7+
nth-*), or arbitrary [&...] variants. Use also when stacking multiple variants
8+
(`md:hover:dark:bg-blue-500`) and the resulting selector does not match the intended
9+
element. Use also when migrating variant chains from Tailwind v3 to v4: the stacking
10+
order flipped from right-to-left to left-to-right and is the #1 silent breakage
11+
during upgrade.
12+
Prevents the common mistakes of (a) reading a v3 stack like `first:*:pt-0` and
13+
expecting the same compiled selector in v4 (it is now `*:first:pt-0`), (b) writing
14+
hover variants that mysteriously stop working on touch devices in v4 (hover is now
15+
gated on `@media (hover: hover)`), (c) writing `!flex` important syntax that fails
16+
silently in v4 (the bang moved to the trailing position: `flex!`), (d) using
17+
unscoped `group-hover:` when a named group `group/sidebar` is required for nested
18+
group hierarchies, (e) trying `not-hover:opacity-75` in v3 (`not-*` is v4-only), and
19+
(f) registering custom variants via `addVariant()` in a v4 project (use `@custom-variant`
20+
instead).
21+
Covers every variant family in v3.4 and v4: pseudo-class (hover, focus, focus-within,
22+
focus-visible, active, visited, target, first, last, odd, even, nth-*, first-of-type,
23+
empty, disabled, enabled, checked, indeterminate, required, valid, invalid, autofill,
24+
read-only, open, inert), pseudo-element (before, after, placeholder, file, marker,
25+
selection, first-line, first-letter, backdrop, details-content, popover-open),
26+
media-query (sm, md, lg, xl, 2xl, max-*, min-[Npx]:, max-[Npx]:, dark, light,
27+
motion-safe, motion-reduce, contrast-more, contrast-less, forced-colors, portrait,
28+
landscape, print, screen, ltr, rtl), feature-query (supports-*, not-supports-*),
29+
attribute (aria-* and aria-[...], data-* and data-[...]), combinator (group-*,
30+
peer-*, named groups `group/name`, named peers `peer/name`, has-*, not-*, in-*,
31+
starting:*, group-has-*, peer-has-*), position-in-parent (*, **, *:first, *:last,
32+
*:odd, *:even, nth-N, nth-[3n+1]), and arbitrary variants ([&:nth-child(3)],
33+
[&_p], [@media(...)], [@supports(...)]). Includes the v4 stacking-order flip with
34+
migration examples, the `@custom-variant` directive (v4) vs `addVariant()` plugin
35+
(v3), the trailing-bang important syntax change, and the hover-on-touch gate.
36+
Keywords: variant, hover, focus, active, dark mode, group-hover, peer-checked,
37+
named group, group/sidebar, peer/name, aria-checked, data-state, has-checked,
38+
not-hover, in-focus, starting, first child, last child, nth-child, odd even,
39+
before after, placeholder, file input, marker, selection, backdrop, motion-reduce,
40+
print only, rtl, arbitrary variant, stacking order, left to right, right to left,
41+
variant order flipped, why does my hover not work on mobile, important modifier,
42+
bang flex, flex bang, custom variant, addVariant, @custom-variant, supports query,
43+
feature query, why does first:*: not work in v4.
44+
license: MIT
45+
compatibility: "Designed for Claude Code. Requires Tailwind CSS v3.4 or v4.0+."
46+
metadata:
47+
author: OpenAEC-Foundation
48+
version: "1.0"
49+
---
50+
51+
# Tailwind CSS Syntax: Variants
52+
53+
Variants are the conditional prefixes that turn a flat utility (`bg-blue-500`) into a state-aware rule (`hover:bg-blue-500`, `md:dark:hover:bg-blue-500`). Tailwind defines around 100 built-in variants spread across ten families plus arbitrary `[&...]` escape hatches. Master this and you master 90% of real-world Tailwind markup.
54+
55+
## Quick Reference
56+
57+
### Variant families at a glance
58+
59+
| Family : signature : v3 : v4 |
60+
|--|
61+
| Pseudo-class : `hover:`, `focus:`, `active:` : same : same (but `hover:` gated on `@media (hover: hover)`) |
62+
| Pseudo-element : `before:`, `placeholder:` : same : same (+ `details-content:`, recognises `popover-open`) |
63+
| Media query : `sm:`, `dark:`, `motion-reduce:`, `print:` : same : same (custom breakpoints via `--breakpoint-*` instead of `theme.screens`) |
64+
| Feature query : `supports-[display:grid]:` : same : same + `not-supports-*:` |
65+
| Attribute : `aria-checked:`, `data-[state=open]:` : same : same |
66+
| Combinator : `group-*`, `peer-*`, named `group/sidebar` : same : same + `has-*`, `not-*`, `in-*`, `peer-has-*`, `group-has-*` |
67+
| Position-in-parent : `first:`, `last:`, `odd:`, `*:` : same | same + parameterised `nth-[3n+1]:`, `nth-last-*:`, `**` (deep descendants) |
68+
| Arbitrary : `[&:nth-child(3)]:`, `[@media(...)]:` : same : same (parens for CSS-vars : `[&_p]:bg-(--brand)`) |
69+
| Important modifier : leading `!flex` (v3) : same (v3) : trailing `flex!` (v4) |
70+
| Custom variant : `addVariant()` plugin (v3) : JS plugin | `@custom-variant name (&:selector);` CSS directive |
71+
72+
### Top 3 most-used patterns
73+
74+
```html
75+
<!-- 1. State + responsive + dark stack (works v3 + v4) -->
76+
<button class="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400 md:px-8">
77+
Save
78+
</button>
79+
80+
<!-- 2. Named group for nested cards (works v3 + v4) -->
81+
<article class="group/card hover:shadow-lg">
82+
<h3 class="text-slate-900 group-hover/card:text-blue-600">Title</h3>
83+
<button class="group/edit invisible group-hover/card:visible">
84+
<span class="group-hover/edit:underline">Edit</span>
85+
</button>
86+
</article>
87+
88+
<!-- 3. Arbitrary variant for one-off selectors -->
89+
<ul class="[&_li]:list-disc [&_li:last-child]:mb-0">
90+
<li>One</li><li>Two</li>
91+
</ul>
92+
```
93+
94+
## Decision Trees
95+
96+
### Picking the right variant family
97+
98+
```
99+
state I care about?
100+
├── interaction state (hover, focus, active, visited, target)
101+
│ └── pseudo-class : `hover:`, `focus:`, ...
102+
├── form/input state (checked, indeterminate, disabled, required, invalid, read-only)
103+
│ └── pseudo-class : `checked:`, `disabled:`, `invalid:`, ...
104+
├── position in parent (first child, last child, odd, every-3rd)
105+
│ ├── known position : `first:`, `last:`, `odd:`, `even:`
106+
│ └── arbitrary position : `nth-[3n+1]:` (v4) or `[&:nth-child(3n+1)]:` (v3 + v4)
107+
├── parent or sibling state?
108+
│ ├── one level of nesting : `group-hover:`, `peer-checked:`
109+
│ ├── multiple nested groups : NAMED `group/sidebar` + `group-hover/sidebar:`
110+
│ └── ancestor-with-attribute without `group` class : `in-[role=tabpanel]:` (v4)
111+
├── presence of descendant with a specific state?
112+
│ └── `has-[input:checked]:` (v4), or `group-has-checked:` if going up first
113+
├── negate any other variant?
114+
│ ├── v4 : `not-hover:`, `not-supports-grid:`
115+
│ └── v3 : NOT available ; use arbitrary `[&:not(:hover)]:`
116+
├── HTML attribute value match (aria-* or data-*)?
117+
│ ├── known value : `aria-checked:`, `data-active:`
118+
│ └── arbitrary : `aria-[sort=ascending]:`, `data-[state=open]:`
119+
├── browser capability test?
120+
│ └── `supports-[display:grid]:`, `not-supports-[backdrop-filter]:` (v4)
121+
├── viewport size or device preference?
122+
│ ├── breakpoint : `sm:`, `md:`, `max-md:`, `min-[400px]:`
123+
│ ├── color scheme : `dark:`, `light:`
124+
│ ├── motion : `motion-reduce:`, `motion-safe:`
125+
│ ├── contrast : `contrast-more:`, `contrast-less:`
126+
│ ├── pointer : `pointer-fine:`, `pointer-coarse:` (v4)
127+
│ ├── reading direction : `ltr:`, `rtl:`
128+
│ └── medium : `print:`, `screen:`
129+
└── nothing built-in fits?
130+
└── arbitrary variant : `[&:nth-child(odd)]:`, `[@media(prefers-reduced-data:reduce)]:`
131+
```
132+
133+
### Picking direction for stacking variants (CRITICAL)
134+
135+
```
136+
Tailwind major version?
137+
├── v3.4 : read RIGHT-TO-LEFT
138+
│ └── `first:*:pt-0` means : direct children (`*`), then first one (`first`)
139+
│ └── selector : `:first-child > *` (incorrect intuition warning)
140+
└── v4.0+ : read LEFT-TO-RIGHT
141+
└── `*:first:pt-0` means : direct children (`*`), then first one (`first`)
142+
└── selector : `> :first-child` (matches CSS reading order)
143+
```
144+
145+
The order **flipped** between v3 and v4. The upgrade tool `npx @tailwindcss/upgrade` catches most cases, but bespoke stacks involving `*:`, structural variants, or arbitrary `[&...]` need manual review.
146+
147+
### Picking how to add a custom variant
148+
149+
```
150+
project Tailwind major version?
151+
├── v3.4 (JavaScript plugin)
152+
│ └── `plugin(({ addVariant }) => { addVariant('third', '&:nth-child(3)') })`
153+
└── v4.0+ (CSS directive)
154+
└── `@custom-variant third (&:nth-child(3));`
155+
```
156+
157+
`addVariant()` from v3 is REMOVED in v4. If you encounter a v3 plugin shipping `addVariant` calls, convert each one into a `@custom-variant` directive during migration.
158+
159+
## Patterns
160+
161+
### Pattern 1: Stacking variants (v3 vs v4)
162+
163+
This is the most consequential v3-to-v4 change. NEVER mix the two mental models.
164+
165+
| v3.4 right-to-left | v4.0+ left-to-right |
166+
|--------------------|----------------------|
167+
| ```html
168+
<!-- v3 syntax -->
169+
<ul class="py-4 first:*:pt-0 last:*:pb-0">
170+
<li>One</li>
171+
<li>Two</li>
172+
</ul>
173+
<!-- Read right-to-left :
174+
pt-0 -> apply to first -> then to direct children (*)
175+
Compiles to : ul > :first-child { padding-top: 0; }
176+
-->
177+
``` | ```html
178+
<!-- v4 syntax -->
179+
<ul class="py-4 *:first:pt-0 *:last:pb-0">
180+
<li>One</li>
181+
<li>Two</li>
182+
</ul>
183+
<!-- Read left-to-right :
184+
direct children (*) -> first one -> pt-0
185+
Compiles to : ul > :first-child { padding-top: 0; }
186+
-->
187+
``` |
188+
189+
Both produce the same CSS, but the WRITTEN order is reversed. The migration tool covers `first:*:`, `last:*:`, `odd:*:`, `even:*:`. Manually inspect any handwritten stacks containing `*:`, `**:`, or arbitrary `[&...]` after a long-chained variant.
190+
191+
### Pattern 2: Named groups for nested hierarchies
192+
193+
Unnamed `group-hover:` always targets the NEAREST `.group` ancestor. When components nest groups, this becomes ambiguous. Named groups make the binding explicit.
194+
195+
```html
196+
<!-- BROKEN : both inner and outer use unnamed group ; group-hover ambiguity -->
197+
<article class="group">
198+
<button class="group">
199+
<span class="group-hover:underline">Which group?</span>
200+
</button>
201+
</article>
202+
203+
<!-- FIXED : named groups -->
204+
<article class="group/card hover:shadow-lg">
205+
<button class="group/edit">
206+
<span class="group-hover/edit:underline">Hovering the button</span>
207+
<span class="group-hover/card:text-blue-600">Hovering the card</span>
208+
</button>
209+
</article>
210+
```
211+
212+
The same pattern applies to peers : `<input class="peer/email">` then `<p class="peer-invalid/email:text-red-500">`.
213+
214+
### Pattern 3: Attribute variants (aria-* and data-*)
215+
216+
```html
217+
<!-- ARIA boolean states (built-in shortcuts) -->
218+
<div aria-checked="true" class="aria-checked:bg-sky-100">Checkbox row</div>
219+
<button aria-pressed="true" class="aria-pressed:ring-2">Toggle</button>
220+
<button aria-disabled="true" class="aria-disabled:opacity-50">Disabled-styled</button>
221+
222+
<!-- ARIA arbitrary values (any attribute value) -->
223+
<th aria-sort="ascending" class="aria-[sort=ascending]:bg-[url('/down-arrow.svg')]">Name</th>
224+
225+
<!-- data-* known shortcuts (active, state, etc.) -->
226+
<div data-active class="data-active:border-purple-500">Selected</div>
227+
228+
<!-- data-* arbitrary values (most common with Radix UI / Headless UI / state machines) -->
229+
<div data-state="open" class="data-[state=open]:rotate-180">Chevron</div>
230+
<div data-side="bottom" class="data-[side=bottom]:slide-in-from-top-2">Tooltip</div>
231+
```
232+
233+
These are essential when integrating Radix UI, Headless UI, or any framework that sets `data-state="open|closed"`, `data-side="top|right|bottom|left"`, or similar attributes.
234+
235+
### Pattern 4: Position-in-parent variants
236+
237+
```html
238+
<!-- Direct children (* prefix) : style only DIRECT children -->
239+
<ul class="*:rounded-full *:border *:px-2 *:py-1">
240+
<li>Pill</li>
241+
<li>Pill</li>
242+
</ul>
243+
244+
<!-- Deep descendants (** prefix) : v4 only -->
245+
<ul class="**:data-avatar:size-12">
246+
<li><img data-avatar src="..." /></li>
247+
<li><img data-avatar src="..." /></li>
248+
</ul>
249+
250+
<!-- Structural pseudo-classes -->
251+
<ul>
252+
<li class="first:pt-0 last:pb-0 only:py-0">...</li>
253+
<li class="odd:bg-white even:bg-slate-50">...</li>
254+
</ul>
255+
256+
<!-- nth-child shortcuts (v4) -->
257+
<div class="nth-3:underline">Third</div>
258+
<div class="nth-[3n+1]:underline">Every 3rd starting at 1</div>
259+
<div class="nth-last-2:underline">Second from end</div>
260+
<div class="nth-of-type-1:font-bold">First of its type</div>
261+
```
262+
263+
### Pattern 5: Has, Not, In selectors (v4)
264+
265+
```html
266+
<!-- has-* : style a parent based on a descendant's state -->
267+
<label class="has-checked:bg-indigo-50 has-checked:ring-2">
268+
<input type="radio" name="plan" />
269+
Standard plan
270+
</label>
271+
272+
<!-- not-* : negate any variant -->
273+
<button class="opacity-75 not-hover:opacity-50">
274+
Default 50%, hover 75%
275+
</button>
276+
<div class="not-supports-[display:grid]:flex">
277+
Falls back to flex when grid not supported
278+
</div>
279+
280+
<!-- in-* : implicit group (no `.group` class required on ancestor) -->
281+
<button class="text-slate-500 in-[role=tablist]:text-slate-900">
282+
Highlights when inside a tablist
283+
</button>
284+
```
285+
286+
All three are v4-only. In v3, replace with arbitrary variants : `has-checked:` becomes `[&:has(:checked)]:`, `not-hover:` becomes `[&:not(:hover)]:`, `in-[role=tablist]:` becomes `[[role=tablist]_&]:`.
287+
288+
### Pattern 6: Custom variants (v3 vs v4)
289+
290+
| v3.4 : JavaScript plugin | v4.0+ : CSS `@custom-variant` |
291+
|---------------------------|------------------------------|
292+
| ```js
293+
// tailwind.config.js
294+
const plugin = require('tailwindcss/plugin')
295+
module.exports = {
296+
plugins: [
297+
plugin(function ({ addVariant }) {
298+
addVariant('third', '&:nth-child(3)')
299+
addVariant('hocus', ['&:hover', '&:focus'])
300+
addVariant('supports-grid', '@supports (display: grid)')
301+
}),
302+
],
303+
}
304+
``` | ```css
305+
@import "tailwindcss";
306+
307+
@custom-variant third (&:nth-child(3));
308+
@custom-variant hocus (&:hover, &:focus);
309+
310+
@custom-variant supports-grid {
311+
@supports (display: grid) { @slot; }
312+
}
313+
``` |
314+
315+
Then use `third:bg-blue-500`, `hocus:underline`, `supports-grid:grid` in markup. The two syntaxes produce equivalent compiled output.
316+
317+
### Pattern 7: Arbitrary variants (escape hatch, both versions)
318+
319+
When no built-in variant fits, the `[&...]` arbitrary syntax accepts any CSS selector or at-rule.
320+
321+
```html
322+
<!-- Custom class selector -->
323+
<li class="[&.is-dragging]:cursor-grabbing">Drag-aware</li>
324+
325+
<!-- Descendant selector -->
326+
<article class="[&_p]:text-slate-600 [&_h2]:text-2xl">
327+
Auto-styles every p and h2 inside
328+
</article>
329+
330+
<!-- Sibling selector -->
331+
<input class="peer [&~p]:hidden focus:[&~p]:block" />
332+
333+
<!-- Custom at-rule -->
334+
<div class="[@media(prefers-reduced-data:reduce)]:bg-none">No bg-image on slow networks</div>
335+
<div class="[@container(width>=600px)]:grid">Container-query escape hatch</div>
336+
337+
<!-- Stacked with built-in variants -->
338+
<li class="[&.is-dragging]:active:cursor-grabbing">Stacks fine</li>
339+
```
340+
341+
The `&` token represents the styled element. Use `_` to mean a space in CSS selectors (`[&_p]` => `& p`).
342+
343+
## Reference Links
344+
345+
- `references/methods.md` : complete variant catalog (every pseudo-class, pseudo-element, media query, feature query, attribute variant, combinator) with v3 vs v4 availability columns.
346+
- `references/examples.md` : working code examples for every family + arbitrary variants + nested groups + Radix data-state patterns.
347+
- `references/anti-patterns.md` : the stacking-order flip mistake, hover-on-touch surprise, leading-vs-trailing bang, ambiguous groups, v4-only variants in v3 projects.
348+
349+
## Verified Sources
350+
351+
- https://tailwindcss.com/docs/hover-focus-and-other-states (v4 variant system + `@custom-variant`)
352+
- https://v3.tailwindcss.com/docs/hover-focus-and-other-states (v3 variant system + `addVariant()`)
353+
- https://tailwindcss.com/docs/upgrade-guide (section 14, stacking-order flip + trailing bang + hover gate)
354+
355+
Last verified : 2026-05-19 (Tailwind CSS v4.x current).

0 commit comments

Comments
 (0)