Mert Özen Further With Every Line
Frontend Tailwind CSS — #04

Variants, Custom Utilities and Modern CSS: group/peer, data-*/aria-*, @container, starting:, @utility and @variant

Mert Özen Nov 15, 2026 11 min 6 views
Variants, Custom Utilities and Modern CSS: group/peer, data-*/aria-*, @container, starting:, @utility and @variant

Every Tailwind variant adds a selector or an at-rule; group-hover also reacts to an outer group's hover when groups are nested, which is why named groups like group/card are needed. With Bookstore's filter and card examples we measure peer, has-*, not-*, in-*, headless styling with aria-expanded and data-[state], @container and v4.3's @container-size, a popover animation with starting:, logical and scrollbar utilities, @utility (--default), @variant and @custom-variant in Playwright.

Version note: The examples in this post were run with Tailwind CSS 4.3.5, Node.js 24 and Playwright 1.65 (Chromium 158).

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, shows a "Book of the week" band at the top of the catalog page. The band is a section with a book card inside. We want the title to change color when you hover the card, so we gave the card group and the title group-hover:text-indigo-600. Later the designer wanted a hover effect on the band itself, and someone added group to the section as well:

<section id="featured" class="group rounded-xl bg-slate-100 p-8">
    <article class="group rounded-lg bg-white p-3">
        <h3 id="title-plain" class="group-hover:text-indigo-600">Dune</h3>
    </article>
</section>

We move the mouse into the padding at the edge of the band, outside the card. Does the book title change color?

This post is about variants: the prefixes that say in which state, under which condition a utility applies. State variants, group and peer, has-*, not-*, in-*, aria-* and data-* for headless components, built-in container queries, enter animations with starting:, the logical property and scrollbar utilities that arrived in v4.2 and v4.3, and finally @utility, @variant and @custom-variant for writing your own utilities and variants. We start with a script that prints the CSS each variant produces; the underlying CSS mechanisms themselves are covered in the CSS3 series.

A Variant Is a Selector

The shortest way to see what a variant does is to look at the CSS a single class produces. 04-selectors.mjs builds each class on its own and prints the utility part:

node scripts/04-selectors.mjs 'focus-visible:outline-2' 'hover:not-disabled:bg-indigo-500'
focus-visible:outline-2
  .focus-visible\:outline-2:focus-visible {
    outline-style: var(--tw-outline-style);
    outline-width: 2px;
  }

hover:not-disabled:bg-indigo-500
  @media (hover: hover) {
    .hover\:not-disabled\:bg-indigo-500:hover:not(:disabled) {
      background-color: var(--color-indigo-500);
    }
  }

Each variant adds a pseudo-class, an attribute selector or an at-rule. Chained variants are applied left to right in v4: hover:not-disabled: adds :hover first, then :not(:disabled). That order was reversed in v3; we come back to the migration side in the maintenance post of this series. hover: only applies on devices that support (hover: hover), so there is no hover state that "sticks" on a touch screen after the finger lifts. For keyboard focus use focus-visible:, not focus:; the ring doesn't show on a mouse click but shows when you arrive with Tab.

group and peer: Styling Based on Another Element's State

The answer to the guess is in the generated selector:

node scripts/04-selectors.mjs 'group-hover:text-indigo-600' 'group-hover/card:text-indigo-600'
group-hover:text-indigo-600
  @media (hover: hover) {
    :is(:where(.group):hover .group-hover\:text-indigo-600) {
      color: var(--color-indigo-600);
    }
  }

group-hover/card:text-indigo-600
  @media (hover: hover) {
    :is(:where(.group\/card):hover .group-hover\/card\:text-indigo-600) {
      color: var(--color-indigo-600);
    }
  }

group-hover: doesn't say "the nearest .group"; it says "any hovered .group ancestor". Since the section is a .group too, hovering the band's padding colors the title. The fix is a named group: the card becomes group/card, the band group/section, and the title listens only to group-hover/card:. With Playwright we moved into the padding of both bands:

node scripts/04-variants.mjs
hover #featured        #title-plain color oklch(0.511 0.262 276.966)
hover #featured-named  #title-named color oklch(0.208 0.042 265.755)

In the unnamed version the title turned indigo-600; in the named version it stayed slate-900. With nested components (a card inside a card, a menu inside a list) named groups should become a habit; when you put a component inside another one, you may not know about the outer group.

peer is the same idea between siblings and uses CSS's ~ combinator. The "In stock" option in Bookstore's filter bar is a hidden checkbox followed by a label:

<input id="in-stock" type="checkbox" class="peer sr-only">
<label for="in-stock" class="rounded-full border px-3 py-1 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:outline-2">In stock</label>
  :is(:where(.peer):checked ~ .peer-checked\:bg-indigo-600) {

Since ~ can only select following siblings, the peer element has to come before the element that looks at it. Put the label in front of the input and nothing happens, and you see no error either.

has-*, not-* and in-*: Looking from the Parent, the Inverse and the Ancestor

The way without an order constraint is has-*: the style sits on the element that contains the state. In the "Hardcover" filter the checkbox is inside the label:

<label id="hardcover" class="rounded-full border px-3 py-1 has-checked:bg-indigo-50 has-checked:border-indigo-600">
    <input type="checkbox" class="accent-indigo-600"> Hardcover
</label>
peer-checked label bg  oklch(0.511 0.262 276.966)
has-checked label bg   oklch(0.962 0.018 272.314)

After clicking both options, both look selected. has-* uses the :has() pseudo-class, which is already within Tailwind v4's browser baseline. not-* inverts, as in the hover:not-disabled: example; according to the v4.0 announcement it can also invert media and @supports queries. in-* removes the need to mark a group; it looks at the state of any ancestor:

node scripts/04-selectors.mjs 'has-checked:bg-indigo-50' 'in-data-[tenant=school]:text-emerald-700'
has-checked:bg-indigo-50
  .has-checked\:bg-indigo-50:has(:checked) {
    background-color: var(--color-indigo-50);
  }

in-data-[tenant=school]:text-emerald-700
  :where([data-tenant="school"]) .in-data-\[tenant\=school\]\:text-emerald-700 {
    color: var(--color-emerald-700);
  }

in-data-[tenant=school]: applies to every element inside the school portal when its root carries data-tenant="school". Since it is inside :where(), only the class itself counts toward specificity.

aria-* and data-*: Styling Headless Components

Headless component libraries (Angular CDK, Angular Aria, Radix and others) provide behavior, not looks: they keep keyboard handling, focus and attributes such as aria-expanded and data-state up to date. Tailwind's variants that read these attributes make extra classes for styling unnecessary. Bookstore's "Format" filter is a disclosure; the script only flips attributes:

<button id="format-button" type="button" aria-expanded="false" aria-controls="format-panel" class="group/trigger inline-flex items-center gap-1">
    Format
    <span id="chevron" class="inline-block transition-transform group-aria-expanded/trigger:rotate-180">▾</span>
</button>
<div id="format-panel" data-state="closed" class="hidden data-[state=open]:block">
    <p>Hardcover, Paperback, E-book</p>
</div>

<script>
    // the disclosure only flips attributes; Tailwind variants do the styling
    const button = document.getElementById("format-button");
    const panel = document.getElementById("format-panel");
    button.addEventListener("click", () => {
        const open = button.getAttribute("aria-expanded") !== "true";
        button.setAttribute("aria-expanded", String(open));
        panel.dataset.state = open ? "open" : "closed";
    });
</script>
aria-expanded=false  chevron rotate none  panel display none
aria-expanded=true  chevron rotate 180deg  panel display block

aria-expanded: is a ready-made variant for the well-known ARIA states, the attributes that report state to screen readers, and produces [aria-expanded="true"]. group-aria-expanded/trigger: reads the same state for the arrow inside the button. data-[state=open]: works for any data attribute and value; the value-less form, like data-current:, only checks that the attribute exists:

aria-expanded:rotate-180
  .aria-expanded\:rotate-180[aria-expanded="true"] {
    rotate: 180deg;
  }

data-current:font-semibold
  .data-current\:font-semibold[data-current] {
    --tw-font-weight: var(--font-weight-semibold);
    font-weight: var(--font-weight-semibold);
  }

The real gain of this approach is accessibility: since the look is derived from the state the screen reader announces, the two cannot drift apart. A menu that looks open while staying aria-expanded="false" is impossible with this style of writing. In the Angular post we build the same filter with Angular's headless tools.

Built-in Container Queries

In v4 container queries need no plugin. @container makes an element an inline-size container, and variants like @md: look at its width. We put the same card into two slots, 300px and 600px wide, in a 1280px viewport:

<div id="narrow" class="@container w-[300px]">
    <article class="flex flex-col gap-3 @md:flex-row">...</article>
</div>
<div id="wide" class="@container w-[600px]">
    <article class="flex flex-col gap-3 @md:flex-row">...</article>
</div>
@container #narrow width 300px  article flex-direction column
@container #wide   width 600px  article flex-direction row

On the container scale @md is 28rem (448px); with the same viewport the cards decided based on their slots. When containers are nested you name them: @container/shelf and @lg/shelf:grid-cols-3. The generated CSS is @container shelf (width >= 32rem). Unlike the breakpoint scale, this scale comes from the --container-* namespace.

v4.3 closed a gap. @container only makes the width queryable, so the height-based cqb and cqh units refer to the viewport rather than the container. @container-size gives container-type: size:

h-[50cqb] in @container-size: 150px, in @container: 400px

In two 300px-tall boxes, h-[50cqb] is 150px in one (half the box) and 400px in the other (half of the 800px viewport). The price of a size container is that the element's height no longer comes from its content, so you must give it an explicit height. We measured how container queries work, and how they clash with subgrid, in the container query post of the CSS3 series.

starting:, Logical Properties and Scrollbars

The starting: variant produces @starting-style: it says which value a transition starts from when an element first appears. The cart preview is a popover:

<button type="button" popovertarget="cart-preview">Cart</button>
<div id="cart-preview" popover class="rounded-lg p-4 opacity-100 shadow-lg transition-all transition-discrete duration-300 starting:open:opacity-0">
    2 books in your cart
</div>
starting:open:opacity-0
  @starting-style {
    .starting\:open\:opacity-0:is([open], :popover-open, :open) {
      opacity: 0%;
    }
  }
popover open, running transitions: opacity 0 -> 1

The moment the popover opens, a transition from 0 to 1 runs for opacity; no JavaScript. Since the open: variant covers [open], :popover-open and :open together, the same class also works for dialog and details. transition-discrete lets discrete properties such as display take part in the transition; you need it for the closing animation.

v4.2 completed the logical property utilities: in addition to the inline-direction ones like ps-* and ms-*, there are now block-direction pbs-*, mbs-*, border-bs, inset-s-* and logical sizes (inline-*, block-*). They follow the text direction:

<div dir="rtl">
    <p id="rtl-note" class="border-s-4 border-amber-500 ps-4 pbs-2">Free shipping over 500 TRY</p>
</div>
rtl ps-4: padding-left 0px, padding-right 16px, border-right-width 4px

In a right-to-left block, "start" is the right side: the padding and the border moved to the right. If Arabic or Hebrew support is coming, writing ps-* instead of pl-* is much cheaper than combing through every template later.

v4.3 added scrollbar utilities. The order list's scrollbar:

<ul id="order-list" class="h-24 overflow-auto scrollbar-thin scrollbar-thumb-slate-400 scrollbar-track-slate-100">
scrollbar: width thin, color oklch(0.704 0.04 256.788) oklch(0.968 0.007 247.896)

scrollbar-thumb-* and scrollbar-track-* are separate classes, yet both write to the standard scrollbar-color property; they do it by combining their own variables (--tw-scrollbar-thumb, --tw-scrollbar-track).

@utility, @variant and @custom-variant

If you need a utility Tailwind doesn't provide, you define it yourself with @utility. We want a striped pattern on the covers of out-of-stock books; the color should come from the palette, gray if none is given:

@import "tailwindcss" source(none);
@source "./custom.html";

/* our own variant: anything inside the school portal */
@custom-variant school (&:where([data-tenant="school"] *));

/* functional utility with a default value (v4.3) */
@utility stripes-* {
    background-image: repeating-linear-gradient(45deg, --value(--color-*, [color], --default(var(--color-slate-300))) 0 6px, transparent 6px 12px);
}

/* a class from the old checkout, kept as plain CSS */
@layer components {
    .legacy-checkout-button {
        background-color: var(--color-indigo-600);
        @variant hover, focus-visible {
            background-color: var(--color-indigo-500);
        }
        @variant school {
            background-color: var(--color-emerald-600);
        }
    }
}

stripes-* is a functional utility: --value(--color-*) accepts a color from the theme, [color] an arbitrary color, and --default(...), new in v4.3, is the value used when none is given. So both stripes and stripes-rose-300 work. One detail: when we wrote --default(--color-slate-300), Tailwind copied the value as is and produced invalid CSS (--color-slate-300 0 6px); the theme variable had to be given inside var().

@variant lets you use variants inside a CSS file. Since v4.3, several comma-separated variants can share the same block (@variant hover, focus-visible). @custom-variant defines a new variant; once defined, school: can be used both as a class in templates and as @variant school in CSS:

@layer components {
  .legacy-checkout-button {
    background-color: var(--color-indigo-600);
    &:hover {
      @media (hover: hover) {
        background-color: var(--color-indigo-500);
      }
    }
    &:focus-visible {
      background-color: var(--color-indigo-500);
    }
    &:where([data-tenant="school"] *) {
      background-color: var(--color-emerald-600);
    }
  }
}
node scripts/04-custom.mjs
checkout          bg oklch(0.511 0.262 276.966)
checkout (focus)  bg oklch(0.585 0.233 277.117)
checkout-school   bg oklch(0.596 0.145 163.225), weight 700
stripes           repeating-linear-gradient(45deg, oklch(0.869 0.022 252.894) 0px, oklch(0.869 0.022 252.894) 6px, rgba(0, 0, 0, 0) 6px, rgba(0, 0, 0, 0) 12px)

Keyboard focus changed the color, the button in the school portal is green and bold, and the striped pattern is the default gray. Classes defined with @utility behave like real utilities: they take variants (hover:stripes-rose-300), go into the utilities layer and are not generated when unused. Had you written the same thing as a plain class in @layer components, none of that would happen.

In Production

Variants have a multiplying effect on the CSS: a color used with hover:, focus-visible:, dark: and two breakpoints means six separate rules. That usually isn't a problem, but if you track the size, the source of growth is often not new utilities but new variant combinations.

starting:, @container-size and the scrollbar utilities rely on new CSS features. Tailwind's compatibility page tells you to check support for features such as @starting-style in your target browsers yourself. In Bookstore these degrade gracefully: without @starting-style the popover opens without animation, without scrollbar-color the default scrollbar shows. Where missing support would lose functionality, such as a height set only through @container-size, provide a fallback value.

A side effect of tying styles to attributes in headless components is stronger tests: a Playwright test can ask "is aria-expanded right?" instead of "is the panel visible?", and both are verified at once.

Common Pitfalls

The first is the pitfall from the guess: using unnamed group-hover: with nested groups. Hovering any outer .group triggers the inner one.

The second is putting peer in the wrong order. peer must always come before the element it affects; if the order cannot change, write has-* on the shared parent.

The third is making arbitrary values a habit: w-[37px], mt-[0.75rem], bg-[#fff]. The canonicalize command in the 4.3.5 CLI we used turns some of these into their token equivalents:

npx tailwindcss canonicalize 'mt-[0.75rem]'
mt-3

We ran the same command for a few classes: p-[1rem] → p-4, bg-[#fff] → bg-white, rounded-[0.25rem] → rounded-sm, grid-cols-[repeat(3,minmax(0,1fr))] → grid-cols-3, bg-[var(--cat-color)] → bg-(--cat-color). w-[16px] and mt-[12px] did not change: the spacing scale is rem-based, so the tool doesn't convert pixels to rem on its own. A value with no counterpart on the scale, like w-[37px], stayed as it was as well; if that value is really needed it is a token candidate, otherwise it should be rounded to the nearest value on the scale. Reading every arbitrary value in code review with the question "why isn't this a token?" catches a drifting design early.

The fourth is expecting a class written in @layer components to behave like a utility. hover:legacy-checkout-button is not generated; if you need your own class that takes variants, use @utility.

A Small Experiment

★☆☆ Move the "In stock" input in 04/index.html behind its label, run npm run build:04 and node scripts/04-variants.mjs, and look at what the peer-checked line shows.

★★☆ Add keyboard support to the format filter: Escape should close the panel and return focus to the button. Use aria-current="true" for the selected format in the panel and style it with aria-current:.

★★★ Add a second dimension to @utility stripes-*: an opacity modifier like stripes-rose-300/50 should work. Use the --modifier(...) function from the Tailwind docs and verify the generated CSS with 04-selectors.mjs.

In the next post we bring all of this into bookstore-web: ng add tailwindcss, @reference in component styles, dynamic class lists with signals, and a filter menu styled on top of Angular's headless tools.