Tailwind CSS Handbook v4.3

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#

app.css
@import "tailwindcss";

@custom-variant theme-midnight (&:where([data-theme="midnight"] *));
HTML
<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.

CSS
@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:

CSS
@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:

CSS
@custom-variant any-hover {
  @media (any-hover: hover) {
    &:hover {
      @slot;
    }
  }
}
HTML
<!-- 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.

CSS
/* 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#

CSS
@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:

CSS
@custom-variant open (&[data-state="open"]);
@custom-variant closed (&[data-state="closed"]);
HTML
<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.

app.css
@import "tailwindcss";

@custom-variant sidebar-open (&:where([data-sidebar="open"] *));
HTML
<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>
JS
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#

CSS
@custom-variant hocus {
  &:hover,
  &:focus-visible {
    @slot;
  }
}
HTML
<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:

CSS
@custom-variant portrait (@media (orientation: portrait));
@custom-variant standalone (@media (display-mode: standalone));
HTML
<!-- 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:

CSS
@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.

CSS
.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:

CSS
.button {
  background: var(--color-sky-500);

  @variant hover:focus {
    background: var(--color-sky-600);
  }
}
Compiled
.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:

CSS
.button {
  background: var(--color-sky-500);

  @variant hover, focus {
    background: var(--color-sky-600);
  }
}
Compiled
.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:

HTML
<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:

HTML
<div class="[&_p]:mt-4">
  <p>Gets a top margin, however deeply nested.</p>
</div>

At-rules work too, and need no &:

HTML
<div class="flex [@supports(display:grid)]:grid">…</div>

Arbitrary variants stack with built-in ones and with each other:

HTML
<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.

HTML
<!-- 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#

app.css
@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;
  }
}
HTML
<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>
Loading preview…

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-variant with no @slot has 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 core open: 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-variant to work inside a component <style> block. Define variants once in your main stylesheet and pull them in with @reference.