Pseudo-classes and the :has() parent selector
The CSS pseudo-classes behind Tailwind's hover, focus, and has- variants — browser-tracked states you style instead of mirroring in React.
You have typed these prefixes for three chapters: hover:bg-accent, focus-visible:ring-2, has-[input:invalid]:border-destructive.
The last one styles a whole <form> red the moment any field inside goes invalid; in 2022 that took a useState, a change handler, and a conditional class.
What you have not met is what the colon-name under each prefix means to the browser.
When does the browser decide an element is :hover?
Why is :focus a bug where :focus-visible is the fix?
How does a <form> know something inside it is invalid with no JavaScript watching?
An earlier lesson taught the prefix grammar: that hover:bg-primary is the CSS rule &:hover { background: … } in shorter syntax.
This lesson teaches what fires underneath, the pseudo-classes as browser primitives, so you can read any :state in a className and know what the browser checks before applying it.
Two anchor the lesson: :focus-visible, the focus reflex that fixes the ugly-ring-on-click problem, and :has(), the selector that lets a parent react to its own contents and retired a whole category of React state.
The interaction pseudo-classes: hover, active, and the focus trio
Section titled “The interaction pseudo-classes: hover, active, and the focus trio”These five pseudo-classes track how the user is interacting with an element. The differences between them are subtle, and the bugs live in those differences, so pin down exactly which one fires when.
:hover matches while the pointer is over the element.
On a touchscreen it does nothing, since there is no pointer to hover, which has consequences we will return to at the end.
:active is the pressed-feedback state: the small acknowledgement that a tap registered.
It holds only while the press is down.
The canonical reach is active:scale-[0.98], a barely-perceptible shrink that makes a button feel physical.
(That shrink is a transform, animated by the motion layer of the next lesson; here, just notice that :active is the state it hangs off.)
:focus matches when the element has focus from any source at all: the user tabbed to it, or clicked it, or your code called .focus() on it.
The “or clicked it” is the whole trap, because a click focuses a button, so :focus fires on click.
:focus-visible matches when the element has focus and the browser has decided a focus indicator should actually be shown — in practice, focus that arrived from the keyboard or from code, not from a plain mouse click.
This is the reflex, the one you put on every button, link, and input.
A keyboard user needs a visible focus indicator — the ring that says “you are here, this is what Enter will press.”
Without it, tabbing through a page is navigating blind.
But :focus fires on mouse clicks too, and a ring that pops up on every click looks like a glitch; designers see it, dislike it, and reach for the worst fix: delete the focus styling entirely.
Now the page looks clean for mouse users and is unusable for keyboard users — an accessibility regression born from an aesthetic complaint about clicks.
:focus-visible resolves that tension for you.
It runs a small internal heuristic that asks whether focus came from the keyboard or the mouse, and matches only when a ring is warranted.
Keyboard users and assistive technology get the ring; mouse-clickers don’t.
The clean look and the accessible behavior, with no trade-off to negotiate.
So the rule, carried straight from the borders-and-elevation lesson: focus-visible: is the default, and bare focus: is the bug.
The stakes are higher than they look: Preflight, the reset from the cascade chapter, strips the browser’s default focus outline, so something has to put a visible focus state back.
That something is :focus-visible.
Leave it out and no one can keyboard through your app.
This only exists under live interaction, so try it.
The exercise below has two buttons: one ringed with bare focus:, one with focus-visible:.
Click each with your mouse, then press Tab to move focus between them.
Watch which rings on a click versus only on a keystroke.
Click each button with your mouse, then press Tab to move between them with the keyboard. The left button uses focus:ring — watch it ring on every click. The right one has no ring yet: add focus-visible:ring-2 focus-visible:ring-blue-500 to it so it stays clean on a click and only rings when you Tab to it. The right button is the reflex. Match the target.
Here is the canonical button string, read as one piece. Every interactive button in a 2026 app carries some version of this:
<button className="hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 active:scale-[0.98]"> Save changes</button>This is app code, so it uses the semantic tokens bg-accent and ring-ring from your theme; the live exercises swap in literal colors like bg-slate-100 and ring-blue-500, because the sandbox can’t load your theme.
The ring-2 ring-ring/50 ring-offset-2 and outline-none are the focus-ring geometry — what the ring is made of and why it doesn’t shove the layout around — and belong to the borders-and-elevation lesson.
This lesson owns focus-visible: itself: the pseudo-class that decides when any of that paint appears.
:focus-within: styling a parent from a focused child
Section titled “:focus-within: styling a parent from a focused child”:hover, :focus, and :focus-visible all describe the element they sit on.
:focus-within describes an ancestor: it matches an element when that element or any descendant inside it has focus.
The classic use is a form row, a label and input wrapped in a bordered container, that lights up its border the moment the input takes focus, drawing the eye to the field you are filling out.
This is the first selector that reaches upward: an element styles itself because of something happening to a child.
Think of :focus-within as a single-purpose parent selector that answers one question, “is anything inside me focused?”
:has(), which you meet shortly, is the general version that can ask any question about an element’s contents.
The Tailwind form is focus-within:, written on the wrapper: a wrapper that rings itself when its input is focused is focus-within:ring-2 focus-within:ring-ring.
This is the shadcn input-group and command-palette pattern, where the whole search bar glows as one unit when you click into the text field.
To read :focus-within across a marked-up tree rather than on a direct wrapper, use group-focus-within:, the same group mechanic from the DOM-state variants lesson.
Try the direct form. Below is a labeled input inside a plain bordered wrapper. Add the variant to the wrapper so the whole row highlights when you focus the input inside it. Then click into the field, or Tab to it, and watch the border and ring respond.
Add a variant to the wrapper div so the whole row highlights when the input inside it is focused. Then click into the field, or Tab to it, and watch the border and ring light up — there is no onFocus handler and no state, the wrapper reads the focus of its own child. Match the target.
This is the cleanest example in the lesson of a pseudo-class deleting state outright.
The 2022 way to highlight that row was a useState(false), an onFocus to set it true, an onBlur to set it false, and a conditional class reading the boolean: four moving parts to track a fact the browser already tracked.
The 2026 way is one variant on the wrapper.
The state was never more than a copy of :focus-within.
Form-state pseudo-classes: disabled, checked, invalid, and friends
Section titled “Form-state pseudo-classes: disabled, checked, invalid, and friends”The next group are pseudo-classes the browser sets from the state of a form control: whether it’s turned off, ticked, or holding a valid value. Each one exposes a property of the control as something you can style.
:disabled matches when a control’s disabled property is set.
Commit one pattern to muscle memory: disabled:opacity-50 disabled:pointer-events-none.
The opacity-50 fades the control so it reads as inert, and pointer-events-none stops it from eating clicks while it’s off.
That pair is the disabled treatment for essentially every button and input in the app.
:checked matches when a checkbox or radio is checked.
On the control itself it’s straightforward, but the reach that matters reads it from elsewhere: the option card.
You wrap a radio in a <label> that styles the whole card, and the card highlights when its radio is checked.
The input holds the :checked state and the card reads it, which is a job for :has() in the next section.
:invalid, :required, :read-only, and :placeholder-shown round out the set: a field’s value fails its constraints, the field is mandatory, it’s read-only, or it’s currently showing its placeholder because it’s empty.
The one that earns its keep is :invalid, almost always read by a parent: has-[input:invalid]:border-destructive on a form row turns the row red the moment the field inside goes invalid.
Be clear on one boundary.
:invalid is a pseudo-class the browser sets that you read for styling, and nothing more.
It is not form validation.
Validating a form on submit, showing error messages, running server-side checks, and deciding whether the form is allowed through is the subject of a later chapter.
Here, :invalid is purely a styling hook: the browser already knows the field is invalid, and you can paint based on that.
Now wire the checked-card pattern yourself, because you’ll reach for it constantly.
Below are three pricing-tier cards, each a <label> wrapping a radio and its text.
Add classes to every label so the card holding the checked radio gets a colored border and a tinted fill.
Then click between the tiers and watch the highlight follow the selection.
Each card is a label wrapping a radio. Add two classes to the shared label string so the card holding the checked radio gets an emerald border (has-[:checked]:border-emerald-500) and a faint emerald fill (has-[:checked]:bg-emerald-50). Then click between the tiers — the radios are native, so nothing you wrote runs on the click; each card just reads its own contents. Match the target.
You just wrote has-[:checked]: and watched a card react to a radio inside it.
That has-[…]: is the central idea of the whole lesson, and it deserves its own section.
:has(): the parent selector that retired a category of React state
Section titled “:has(): the parent selector that retired a category of React state”For almost the entire history of CSS, a selector could only look down or sideways: you could style an element by its own state or by an ancestor’s, but never by what it contains. A parent could not react to its children. That one gap is why so much “the container changes when something inside it changes” logic had to live in JavaScript, with state mirroring the DOM.
:has() closes the gap.
It selects an element by what it contains: el:has(.x) reads “an el with a matching .x somewhere inside it.”
The Tailwind form is the has-[<selector>]: you just used, and the one-line hook from the DOM-state variants lesson holds here too: has-[…]: is &:has(…), the same selector-wrapped-around-a-utility move as every variant, just pointed inward.
What matters is not the syntax but what each use deletes. Three cases, and the React each one retires:
has-[input:invalid]:border-destructiveon a form row replaces a validity observer, asetState, and a conditional class.has-[:checked]:bg-accenton a label-card replaces anonChangehandler that copied the input’scheckedinto React state just to add a border.has-[img]:p-0on a card (versusp-6when it has no image) replaces a prop check and a conditional className, or an entirely separate component.
All three are the spine the DOM-state variants lesson built: the DOM already knows, :has() reads it, and the React state was always just a mirror of a fact the DOM held the whole time.
Each :has() deletes state you would otherwise keep in sync.
Scrub through the three cases below. The left of each step is the 2022 React — state, a handler, a conditional class. The right is the 2026 one-liner that replaces it.
const [isInvalid, setIsInvalid] = useState(false) return ( <div className={cn('border', isInvalid && 'border-destructive')}> <input onBlur={e => setIsInvalid(!e.target.validity.valid))} /> </div> )
<div className="border has-[input:invalid]:border-destructive"> <input /> </div>
A form row that goes red when its field is invalid. The 2022 left keeps an isInvalid boolean, a handler watching the input’s validity, and a cn() branching on it. The 2026 right is border plus has-[input:invalid]:border-destructive: the row reads its own field’s :invalid directly, so the state and the handler both come out. (The :has() side is a static illustration; in a non-live panel it can’t fire.)
const [checked, setChecked] = useState(false) return ( <label className={cn('border', checked && 'bg-accent')}> <input type="radio" onChange={e => setChecked(e.target.checked))} /> </label> )
<label className="border has-[:checked]:bg-accent"> <input type="radio" /> </label>
An option card that highlights when its radio is checked. The left copies the radio’s checked into React state through an onChange, purely to add a fill. The right is has-[:checked]:bg-accent on the label: the card reads the radio it wraps, deleting both the state and the handler.
// often a whole second component, or: function Card({ hasImage, children }) { return ( <div className={cn('p-6', hasImage && 'p-0')}> {children} </div> ) }
<div className="p-6 has-[img]:p-0"> {children} </div>
A card that goes flush when it contains an image, padded when it doesn’t. The left threads a hasImage prop through a conditional class, often a whole second component. The right is one shared string, p-6 has-[img]:p-0: the card reads its own contents and decides.
The Tailwind bracket takes any selector: has-[img]:, has-[:checked]:, has-[input:invalid]:, has-[[data-state=open]]: — whatever you can write as a CSS selector.
When the reacting element isn’t the direct container, group-has-[<selector>]: reads “an ancestor marked group that contains the match,” the same group mechanic from the DOM-state variants lesson.
Plain has-[…]: is the workhorse; you mostly just need to recognize the group- form when you see it.
:has() has been Baseline since December 2023, so it works everywhere your SaaS runs, with no polyfill or fallback to write.
Both cards share the EXACT same className — and right now both are padded, so Card A's image is inset awkwardly. Add one has-[…]: variant to that shared string so a card containing an image goes edge-to-edge while a card without one keeps its padding. Same class on both; the content decides the layout. Match the target.
Same class string, two layouts, and only the content varied. No prop, no branch, no second component: the card read itself and decided.
Two limits to know.
First, :has() chains — has-[input:checked]:has-[.required]: requires two conditions at once — but past two conditions the line stops being readable at a glance.
Rather than reach deeper into the selector, set a single data-* attribute where you compute the condition and read it with the data-[…]: variant from the DOM-state variants lesson.
The selector can go deeper; you just shouldn’t make it.
Second, :has() does not reach inside shadow DOM .
The internals of some native widgets, like a <select>’s option list, live in a sealed subtree your selectors can’t see into.
To style those, lean on the form library rather than fighting :has() past its reach.
:not(): negating a state
Section titled “:not(): negating a state”You reach for :not() when a different state has spilled onto the wrong element.
:not(<selector>) matches every element that does not match the selector inside it.
The Tailwind form is not-*:, where * is the state you’re negating: not-disabled:, not-first:, not-last:.
The bracket takes any state, not just position.
The reach that earns the pseudo-class is the disabled-hover trap.
Give a button hover:bg-accent, then disable it.
In some browsers a disabled button still fires :hover, so the hover background paints on a control the user can’t use, which reads as broken.
Gate the hover on the button not being disabled:
<button className="bg-primary not-disabled:hover:bg-accent disabled:opacity-50 disabled:pointer-events-none" disabled={isPending}> Save changes</button>Read the three states as a set.
not-disabled:hover:bg-accent is the live hover, applying only while the button works.
disabled:opacity-50 disabled:pointer-events-none is the off state.
Together they give a real hover when the button works, a faded look when it doesn’t, and never a hover style on a dead control.
Make not-disabled:hover: the reflex for any button that can be disabled.
The other use is sibling resets.
not-last:border-b puts a bottom border on every row except the last, so a list gets dividers between rows with no trailing line.
Recognize it, but gap and divide-* retired most of that work, so you’ll write not-last: far less than the old code you’ll read.
Below is a button className with two blanks. Pick the hover utility and decide whether the disabled fade belongs.
This Save button can be disabled while a request is in flight. Fill the blanks so its hover only fires when it's actually clickable, and so it reads as inert when disabled. Pick the right option from each dropdown, then press Check.
<button disabled={isPending} className="rounded-md bg-blue-600 px-4 py-2 text-white ___ ___"> Save changes</button>Pseudo-elements and the placeholder-color fix
Section titled “Pseudo-elements and the placeholder-color fix”Now switch from states to sub-parts. Everything so far has been a pseudo-class, a state the browser tracks on a real element. The two prefixes here are pseudo-elements: they target a piece of an element that your markup never created as its own tag, which is why raw CSS spells them with two colons. Tailwind hides the colons, but the distinction is real.
The first one fixes a common bug.
::placeholder targets the faint hint text an input shows while empty, and here is the catch: the placeholder does not inherit color.
Set your input’s text color and the placeholder ignores it, rendering at the input’s full text color, so the hint looks exactly like a real typed-in value and users try to “clear” text that isn’t there.
Add placeholder:text-muted-foreground (the muted token from the color lesson) to every text input as a reflex.
The second is optional polish.
::selection targets the highlight that appears when the user drags to select text, and selection:bg-primary selection:text-primary-foreground brands it in your colors instead of the OS default blue.
Nice on a marketing surface, entirely optional.
A third, ::file-selector-button, styles the button inside an <input type="file">; worth knowing it exists.
The placeholder bug is invisible until you see it live. The input below has no placeholder styling, so the placeholder reads like typed text. Add the fix so it recedes to a hint.
This input has no placeholder styling, so its placeholder renders at full text color and reads like something already typed. Add one class so the placeholder recedes to a faint hint (placeholder:text-slate-400). Match the target. (In real app code this is placeholder:text-muted-foreground — the sandbox can't load your theme token, so we use a literal gray here.)
Structural and link pseudo-classes
Section titled “Structural and link pseudo-classes”A few pseudo-classes are worth recognizing without mistaking them for daily tools, because most of what they did has been retired.
Structural pseudo-classes match an element by its position among siblings: :first-child, :last-child, :nth-child(n), :empty, and the -of-type variants.
They’re mostly retired, because gap (from the layout chapter) and divide-* (from the borders-and-elevation lesson) took over the “put space or a line between siblings” work that drove most :nth-child reaches.
The one that still earns its place is :empty: a container with empty:hidden disappears when it has no children, the clean way to handle an empty state.
Link pseudo-classes are :link (not yet visited) and :visited (visited).
:visited is privacy-locked: browsers let you change only a handful of properties on it (color, background-color, border-color, a few more) so a page can’t probe your history by measuring a link’s computed style.
It matters in long-form prose where links are content; in app UI, where links are navigation, you’ll rarely touch it.
One more for recognition: :target matches the element whose id is in the URL’s hash, the basis for some hash-driven UI.
Forcing element state in DevTools
Section titled “Forcing element state in DevTools”Suppose a hover or focus style looks wrong and you want to inspect it.
You hover the element to trigger the style, but the instant you move your mouse toward DevTools, the hover drops.
You can’t hold the state and read the Styles panel at once.
:focus-visible is harder still, since it depends on how focus arrived.
Every browser’s DevTools has a button to pin a state, and reaching for it is the reflex the moment a state-driven style misbehaves. Here’s the path in Chrome and Edge.
-
Right-click the element and choose Inspect to highlight it in the Elements panel.
-
In the Styles panel, click the
:hovbutton. Its tooltip reads Toggle Element State. -
Tick the state to pin:
:hover,:focus,:focus-visible,:active, or:target. It’s now forced on and stays on wherever your mouse goes. -
Read the matched rules while the state holds, then untick to release. Firefox and Safari have the same control in their inspector’s rules panel.
With the state pinned, the matched rules sit still and you can find whatever’s overriding your intended style.
Check your understanding
Section titled “Check your understanding”Some of these pseudo-classes name a state on the element itself; others describe an ancestor reacting to a descendant. Sort the lesson’s by that line, dropping each into the bucket for what it reacts to.
Sort each pseudo-class by what it reacts to — a state on the element itself, or an ancestor reacting to a descendant inside it. Drag each item into the bucket it belongs to, then press Check.
:hover:focus-visible:active:disabled:checked:invalid:focus-within:has(…)Now the two decisions worth making automatic: when to reach for focus-visible:, and when :has() can replace state.
A teammate reports that your primary button flashes a focus ring every time it’s clicked with the mouse, and it looks like a glitch. What’s the correct fix?
ring-* utilities behind the focus-visible: prefix instead of focus:, and let the browser decide when a ring is warranted.ring-* utilities off the button so the ring stops appearing.active: so it only paints while the button is held down.useState flag toggled by onKeyDown/onBlur and gate the ring on that.focus: fires on a mouse click as well as on keyboard focus. focus-visible: hands the keyboard-vs-mouse decision to the browser’s heuristic, so the ring shows for keyboard and programmatic focus and stays hidden on a plain click — clean look, no accessibility cost. Removing the ring is the regression this pseudo-class exists to prevent: it leaves keyboard users with no “you are here” indicator. active: only shows feedback while the button is pressed, not while it holds focus, and a useState flag rebuilds in JavaScript a fact the browser already tracks.An option card keeps const [isChecked, setIsChecked] = useState(false), flipped by an onChange on the radio it wraps, and the boolean is read by exactly one conditional class that adds a border when the tier is selected. You want to delete the state. What single change replaces all of it?
has-[:checked]:border-primary on the card and remove the state, the handler, and the conditional — the card now reads the radio it contains.useState in place but move the setIsChecked call out of onChange and into a useEffect that watches the radio.peer-checked:border-primary from the sibling that sits before it.onChange, but have it set a data-checked attribute on the card and style that with data-[checked]:border-primary.:has() is for: has-[:checked]: reads the wrapped radio’s :checked at the source, so the useState, the onChange, and the conditional class all come out together. Moving the setter into a useEffect keeps every piece of state you set out to delete. peer-* reads a sibling, not a contained child, so it can’t see a radio the card wraps. And setting a data-checked attribute from an onChange keeps the very handler — and the JS round-trip — that the whole point was to remove; the DOM already holds :checked, so there’s nothing to copy.Each of these flips its style the instant the browser’s state changes, with nothing in between. Next you’ll make those flips move, using transitions and keyframe animation to turn an instant switch into something that glides.
External resources
Section titled “External resources”The authoritative index of every pseudo-class, with what each one matches and when the browser sets it.
The definitive deep-dive on focus rings from an accessibility expert — contrast, sizing, and the :focus-visible keyboard-vs-mouse split, with WCAG-conformant CSS.
A worked tour of :has() beyond the parent selector — previous-sibling selection, ranges, star ratings — each with a live CodePen.
Every pseudo-class in this lesson mapped to its Tailwind variant prefix, with the exact bracket forms for has-, not-, and the rest.