Tailwind CSS Intermediate: Theme, Variants and Reuse
Advanced Variants
In this lesson you will
- Use group and peer variants to react to related elements
- Style from data and aria attributes
- Use has, not, container-query and other modern variants
Introduction
Basic variants style an element based on its own state. Real interfaces also need an element to react to another element: a card that highlights its title on hover, a label that turns red when its input is invalid. Tailwind has variants for these cases. I compiled every example here with Tailwind 4.3.3 and describe the real generated selectors.
group: react to a parent
Mark the parent with group, then use group-hover: (or other group-*) on children.
HTML
<a href="/post" class="group block rounded-xl border border-gray-200 p-4">
<h3 class="font-semibold group-hover:underline">Post title</h3>
<span class="text-gray-500 group-hover:text-blue-600">Read more</span>
</a>The child rule compiled to a selector like .group-hover\:underline:is(:where(.group):hover *). In words: apply when the child is inside an element with class group that is hovered.
For nested groups, name them: group/card on the parent and group-hover/card:underline on the child. I compiled group-hover/item:underline and it targeted only .group/item.
peer: react to a sibling
Mark an element with peer, then style later siblings with peer-*.
HTML
<input type="email" class="peer rounded border px-3 py-2" required />
<p class="hidden text-sm text-red-600 peer-invalid:block">Enter a valid email.</p>The message appears when the preceding input is invalid. I confirmed peer-checked: compiles to :where(.peer):checked ~ *, which uses the general sibling combinator. That is why the peer must come before the element you style.
Quick quiz
Where must a peer element be placed relative to the element using peer-checked?
data and aria attributes
Style from state stored in attributes. This works well with UI libraries that set data-state or aria-expanded.
HTML
<button aria-expanded="true" class="rotate-0 aria-expanded:rotate-180">
Toggle
</button>
<div data-state="open" class="hidden data-[state=open]:block">Panel</div>aria-expanded:compiled to[aria-expanded="true"].data-active:compiled to[data-active](the attribute is present).data-[state=open]:compiled to[data-state="open"].aria-[sort=ascending]:compiled to[aria-sort="ascending"].
This is a clean way to style from accessibility state, which you should be setting anyway.
has and not
has-checked:styles an element when it contains a checked control. I compiledhas-checked:bg-blue-50to:has(:checked). It is great for highlighting a selected option card:
HTML
<label class="block rounded-lg border p-4 has-checked:border-blue-600 has-checked:bg-blue-50">
<input type="radio" name="plan" />
Pro plan
</label>not-hover:styles when something is not the state:not-hover:opacity-75. It compiled to:not(:hover)plus a media query for devices without hover.in-*styles from any ancestor state without marking it as a group, for examplein-data-active:underline.
Other handy variants
| Variant | Applies when |
|---|---|
open: | details or dialog is open ([open], :popover-open) |
user-invalid: | invalid after the user interacted (avoids red fields on load) |
inert: | element is inert |
nth-3: | it is the third child |
first: / last: / odd: / even: | position |
motion-safe: / motion-reduce: | reduced-motion preference |
forced-colors: | high-contrast mode |
user-invalid: is very practical, because plain invalid: can show errors before the user has typed anything.
Container queries
Media queries depend on the viewport. Container queries depend on the size of a parent container, so components adapt wherever they are placed.
HTML
<div class="@container">
<div class="grid grid-cols-1 gap-4 @md:grid-cols-2 @lg:grid-cols-3">
...
</div>
</div>Mark the parent with @container. The @md: variant compiled to @container (width >= 28rem) and @lg: to (width >= 32rem). A card component with container variants works in a narrow sidebar and a wide main area without knowing the screen size.
starting: for entry animations
starting: maps to CSS @starting-style, which defines the style an element animates from when it first appears:
HTML
<div class="transition-opacity duration-300 starting:opacity-0">Fades in</div>Browser support for @starting-style is newer, so check it against your audience.
Stacking order
Variants apply left to right. md:dark:bg-blue-500 and dark:md:hover:bg-blue-500 both work and produce nested conditions. In v4 the order is left to right, which changed from v3, so a v3 class such as first:*:pt-0 is written *:first:pt-0.
Try it yourself
Build a selectable plan card with has-checked:, an accordion button with aria-expanded:rotate-180, and a form field that shows an error with peer-user-invalid:. Check each with the keyboard as well as the mouse.
Summary
groupandpeerlet an element react to a parent or earlier sibling, and named groups handle nesting.- Style from
data-*andaria-*attributes, and usehas-*,not-*andin-*for relationships and negation. - Container queries (
@container,@md:) adapt to a parent's width, andstarting:animates elements as they appear.
Lesson complete
+20 XP
Nice work. Keep going while it is fresh.
Sources and further reading