Custom Variants
Define variants with @custom-variant, apply them inside your own CSS with @variant, and understand :where(), stacking order and arbitrary variants.
A variant is a rule that wraps a utility in a condition — a selector, a media query, an at-rule. @custom-variant lets you register your own, so sidebar-open:translate-x-0 becomes as ordinary as hover:bg-blue-600.
Overview#
@import "tailwindcss";
@custom-variant theme-midnight (&:where([data-theme="midnight"] *));<html data-theme="midnight">
<button class="bg-white theme-midnight:bg-black">…</button>
</html>The new variant composes with everything else: md:theme-midnight:hover:bg-gray-900 works with no extra configuration.
The two syntaxes#
Shorthand#
When the variant is a single selector, put it in parentheses. & stands for the element the utility is applied to.
@custom-variant pointer-hover (&:hover);
@custom-variant sidebar-open (&:where([data-sidebar="open"] *));
@custom-variant supports-grid (@supports (display: grid));Block form with @slot#
When you need nesting, use a block and mark where the utility's declarations go with @slot:
@custom-variant theme-midnight {
&:where([data-theme="midnight"] *) {
@slot;
}
}The two forms above are equivalent. The block form exists for cases the shorthand cannot express.
Multiple nested rules#
@slot can sit any number of levels deep, which is how you combine a media query with a selector:
@custom-variant any-hover {
@media (any-hover: hover) {
&:hover {
@slot;
}
}
}<!-- Only hovers on devices that actually have a hovering pointer -->
<button class="any-hover:bg-indigo-600">…</button>hover: in v4 already wraps itself in @media (hover: hover). any-hover is the looser check — it matches if any available input can hover, which is what you want on a laptop with a touchscreen.
Why :where()#
Nearly every selector-based custom variant in the wild wraps its condition in :where(). That is not decoration.
/* Zero specificity — dark:bg-white weighs the same as bg-white */
@custom-variant dark (&:where(.dark, .dark *));
/* Specificity (0,2,0) — outranks every plain utility */
@custom-variant dark (.dark &);:where() always contributes zero specificity, no matter what is inside it. Without it, dark:bg-white would be a two-class selector and would beat bg-white, md:bg-white and every other single-class utility. Ordering would stop working, and you would start reaching for ! to fix it.
Worked examples#
A theme variant#
@custom-variant theme-ocean (&:where([data-theme="ocean"], [data-theme="ocean"] *));Listing the element itself and its descendants means data-theme="ocean" on <html> styles the whole document, and the same variant also works when applied to a single section.
A data-attribute variant#
Tailwind can already do data-[state=open]: inline. Register a variant when the attribute appears often enough that the inline version becomes noise:
@custom-variant open (&[data-state="open"]);
@custom-variant closed (&[data-state="closed"]);<div data-state="open" class="opacity-0 open:opacity-100 closed:pointer-events-none">…</div>A sidebar-open variant#
A common app-shell pattern: one attribute on a wrapper drives several unrelated elements.
@import "tailwindcss";
@custom-variant sidebar-open (&:where([data-sidebar="open"] *));<div data-sidebar="closed" id="shell">
<aside class="w-64 -translate-x-full transition-transform sidebar-open:translate-x-0">…</aside>
<div class="pointer-events-none opacity-0 transition-opacity sidebar-open:pointer-events-auto sidebar-open:opacity-100">…</div>
<main class="transition-[margin] sidebar-open:ml-64">…</main>
</div>document.getElementById('shell').dataset.sidebar = 'open'One attribute change, three coordinated transitions, no per-element class juggling in JavaScript.
hocus — one variant for two states#
@custom-variant hocus {
&:hover,
&:focus-visible {
@slot;
}
}<a class="text-gray-600 hocus:text-indigo-600 hocus:underline" href="#">…</a>This is a genuine win over hover:text-indigo-600 focus-visible:text-indigo-600 — you can no longer forget one half of the pair.
An at-rule variant#
With at-rules, & is unnecessary — the utility nests inside directly:
@custom-variant portrait (@media (orientation: portrait));
@custom-variant standalone (@media (display-mode: standalone));<!-- Hide the "install app" prompt when already installed -->
<div class="standalone:hidden">…</div>dark is just a custom variant#
There is no darkMode option in v4 because there is nothing special about dark:. It is a variant defined the same way yours are, and you redefine it by declaring it again:
@import "tailwindcss";
/* Replaces the default prefers-color-scheme behaviour entirely */
@custom-variant dark (&:where(.dark, .dark *));Any variant name can be overridden this way. See Dark Mode for the full strategy comparison.
The @variant directive#
@custom-variant defines a variant. @variant uses one, inside your own CSS, so you do not have to remember what selector or media query a variant compiles to.
.my-element {
background: white;
@variant dark {
background: black;
}
}This is most valuable for variants whose implementation you would otherwise have to duplicate — if you later switch dark from a media query to a class, every @variant dark block follows automatically.
Stacked variants v4.3#
Chain variants with colons exactly as you would in markup:
.button {
background: var(--color-sky-500);
@variant hover:focus {
background: var(--color-sky-600);
}
}.button {
background: var(--color-sky-500);
&:hover {
@media (hover: hover) {
&:focus {
background: var(--color-sky-600);
}
}
}
}Comma-separated variants v4.3#
Share one block across several variants with a comma:
.button {
background: var(--color-sky-500);
@variant hover, focus {
background: var(--color-sky-600);
}
}.button {
background: var(--color-sky-500);
&:hover {
@media (hover: hover) {
background: var(--color-sky-600);
}
}
&:focus {
background: var(--color-sky-600);
}
}Note the difference: hover:focus requires both conditions, hover, focus matches either. Before v4.3 you had to nest two @variant blocks or repeat the declaration.
Arbitrary variants in markup#
For a one-off, skip the definition entirely and write the selector inline in square brackets:
<li class="[&:nth-child(-n+3)]:font-semibold">…</li>
<li class="[&.is-dragging]:cursor-grabbing">…</li>Underscores become spaces, which is how you write a descendant selector:
<div class="[&_p]:mt-4">
<p>Gets a top margin, however deeply nested.</p>
</div>At-rules work too, and need no &:
<div class="flex [@supports(display:grid)]:grid">…</div>Arbitrary variants stack with built-in ones and with each other:
<li class="lg:[&:nth-child(-n+3)]:hover:underline">…</li>Variant stacking order#
Changed in v4 Stacked variants apply left to right. The leftmost variant is the outermost wrapper.
<!-- The first child of every direct child -->
<ul class="*:first:pt-0">…</ul>In v3 this read right to left, so the same intent was written first:*:pt-0. If you are porting v3 markup, any order-sensitive stack needs reversing.
For most pairs the order makes no difference to the output — md:hover: and hover:md: produce equivalent CSS. It matters when one variant changes what & refers to: *:, **:, group-*, peer-* and arbitrary variants containing combinators.
Advanced example: a variant-driven data table#
@import "tailwindcss";
@custom-variant row-selected (&:where([data-selected="true"], [data-selected="true"] *));
@custom-variant sortable (&:where([data-sortable="true"]));
@custom-variant hocus {
&:hover,
&:focus-visible {
@slot;
}
}<table class="w-full text-sm">
<thead>
<tr>
<th data-sortable="true" tabindex="0"
class="px-3 py-2 text-left font-medium sortable:cursor-pointer hocus:text-indigo-600">
Name
</th>
</tr>
</thead>
<tbody>
<tr data-selected="true" class="border-t row-selected:bg-indigo-50">
<td class="px-3 py-2 row-selected:font-medium row-selected:text-indigo-900">Ada Lovelace</td>
</tr>
</tbody>
</table><div class="p-4">
<table class="mx-auto w-full max-w-md overflow-hidden rounded-lg border border-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="cursor-pointer px-3 py-2 text-left font-medium text-gray-700 hover:text-indigo-600 focus-visible:text-indigo-600" tabindex="0">Name</th>
<th class="px-3 py-2 text-left font-medium text-gray-700">Role</th>
</tr>
</thead>
<tbody class="bg-white">
<tr class="border-t border-gray-200 bg-indigo-50">
<td class="px-3 py-2 font-medium text-indigo-900">Ada Lovelace</td>
<td class="px-3 py-2 text-indigo-700">Selected</td>
</tr>
<tr class="border-t border-gray-200 hover:bg-gray-50">
<td class="px-3 py-2 text-gray-900">Grace Hopper</td>
<td class="px-3 py-2 text-gray-500">Idle</td>
</tr>
</tbody>
</table>
</div>The rendered result above uses the equivalent core variants — the custom ones exist so the intent (row-selected:, sortable:) is legible in the markup rather than encoded in attribute selectors.
Common mistakes#
- Omitting
:where()on ancestor selectors..dark &outranks every utility and quietly breaks your override order. - Forgetting
@slot. A block-form@custom-variantwith no@slothas nowhere to put the utility's declarations and generates nothing. - Using
&in an at-rule variant.@custom-variant portrait (@media (orientation: portrait))is complete on its own; adding&is a syntax error. - Shadowing a built-in name. Declaring
@custom-variant open (…)replaces the coreopen:variant everywhere, including in code you did not write. - Reversing v3 stacking order on paste.
first:*:from v3 must become*:first:in v4. - Expecting
@custom-variantto work inside a component<style>block. Define variants once in your main stylesheet and pull them in with@reference.
Related topics#
- States & Variants — the built-in variants
- Dark Mode —
darkas a custom variant in practice - Custom Utilities — the
@utilityhalf of the story - Variant Reference — every variant in one table
- Functions & Directives —
@variant,@custom-variantand the rest