arbitrary variants — write a CSS selector in your class name
Every Tailwind variant is sugar over one idea: append a selector to
the element. hover: appends :hover,
first: appends :first-child. Arbitrary
variants expose that mechanism raw: write the selector yourself in
square brackets, use & to mark where the
element goes, and Tailwind splices it into the compiled rule. No
@custom-variant, no config — it works straight from HTML.
| you write | Tailwind compiles | selector family |
|---|---|---|
[&:nth-child(3)]:text-cyan-400 |
.el:nth-child(3) { color:… } |
pseudo-class |
[&::-webkit-slider-thumb]:bg-cyan-500 |
.el::-webkit-slider-thumb { … } |
pseudo-element |
[&[aria-pressed=true]]:bg-cyan-600 |
.el[aria-pressed=true] { … } |
attribute selector |
[&_.inner]:text-cyan-400 |
.el .inner { … } |
descendant combinator |
[&>div:first-child]:mt-0 |
.el > div:first-child { … } |
child + structural |
[@supports(display:grid)]:grid |
@supports (display:grid) { .el { display:grid } } |
feature query (at-rule) |
[@media(print)]:hidden |
@media print { .el { display:none } } |
media query (at-rule) |
The & is a placeholder. It means "the element
wearing this class" — the same & native CSS nesting and
Sass use. Put it anywhere: [&:hover] (after),
[.parent_&] (before), [&_&]
(nested). For at-rules (@supports, @media) the
whole condition lives inside the brackets and & is
optional — Tailwind knows where the element goes.
1 · [&:nth-child(3)]: — pseudo-class on the fly
The list below carries one arbitrary variant: the 3rd item has
[&:nth-child(3)]:text-cyan-400. That is the ONLY rule
deciding its color — no nth-3: shorthand, no JS. Click any
item to read its computed color and confirm only #3 is cyan.
This is the gold-checked assertion.
- item #1 — default ink
- item #2 — default ink
-
item #3 —
[&:nth-child(3)]:text-cyan-400 - item #4 — default ink
- item #5 — default ink
click an item → its computed color appears here
2 · [&::-webkit-slider-thumb]: — pseudo-element styling
The range input's thumb is a pseudo-element —
::-webkit-slider-thumb (Chromium/Safari) /
::-moz-range-thumb (Firefox). There is no built-in Tailwind
variant for it, so arbitrary variants are the only clean path. Drag the
slider — the cyan thumb is shaped entirely by
[&::-webkit-slider-thumb]: rules.
value: 42 — thumb styled by 9 arbitrary pseudo-element variants
Two vendor prefixes, one input. Both
[&::-webkit-slider-thumb]: and
[&::-moz-range-thumb]: are stacked — the browser
silently ignores the one it doesn't understand. This is the standard
pattern for cross-browser range styling in v4.
3 · [&_.target]: — descendant combinator
The card carries [&_.target]:text-cyan-400. The
&_. reads as "this element, then a space (descendant),
then .target". Every descendant tagged
target lights up cyan — at any depth — while un-tagged
siblings stay slate. This is parent-scoped styling without
group-* or a component boundary.
card root (no target class) — stays slate
paragraph.target — turns cyan
- plain li — slate
- li.target — cyan (the variant reaches into list items too)
.target color: — · sibling color: —
4 · [@supports(display:grid)]: — feature query variant
The box carries TWO complementary at-rule variants:
[@supports(display:grid)]:grid (apply if grid is supported)
and [@supports(not(display:grid))]:block (fallback). Every
modern browser supports grid, so display resolves to
grid — proving the variant compiled into a real
@supports block. The grid layout is the proof: the 3 cells
sit on one row. not-supports-[display:grid]: is the
shorthand alias Tailwind ships for the negation.
box display: — · grid-template-columns: —
stacking arbitrary + built-in variants
Arbitrary variants compose with every other variant — just chain them left-to-right, outermost first. Order matters the same way it does for built-ins: each variant wraps the next.
| stacked class | compiles to | when it fires |
|---|---|---|
md:[&:nth-child(3)]:bg-cyan-900 |
@media (min-width:48rem) { .el:nth-child(3) {…} } |
3rd child, only at ≥ 768px viewport |
hover:[&~.next]:text-cyan-400 |
.el:hover ~ .next {…} (hover sets it, sibling reads it) |
when this element is hovered (advanced — usually use peer-*) |
dark:[&::-webkit-scrollbar]:w-2 |
@media (prefers-color-scheme:dark) { .el::-webkit-scrollbar {…} } |
dark mode + webkit scrollbar pseudo-element |
[@media(print)]:[&:first-child]:hidden |
@media print { .el:first-child { display:none } } |
print media + first child — at-rules stack too |
intent → arbitrary variant
| intent | pattern | why arbitrary |
|---|---|---|
| style the Nth child (no shorthand) | [&:nth-child(3)]: |
v4 ships nth-3: for simple cases; arbitrary covers nth-child(2n+1) etc. |
| style a vendor pseudo-element | [&::-webkit-slider-thumb]: |
no built-in variant exists for slider thumbs / scrollbars |
| react to a data/aria attribute value | [&[aria-pressed=true]]: |
aria-pressed: only matches "true"; arbitrary matches any value/attr |
| descendant of this element | [&_.inner]: |
scoped styling without group-* markers |
| direct-child selector | [&>div]: |
reaches only immediate children, not all descendants |
| feature detection | [@supports(display:grid)]: |
progressive enhancement; supports-[…]: is the alias |
| print / media-only styles | [@media(print)]: |
print: covers print; arbitrary covers any @media expression |
| ancestor-scoped (element is INSIDE .x) | [.x_&]: |
compiles to .x .el — style this element only when nested in .x |