Pseudo-classes and the :has() parent selector
The CSS pseudo-classes behind Tailwind's hover, focus, and has- variants, the browser-tracked states you style instead of mirroring in React.
You have been writing these prefixes for three chapters now. Styling a button on hover is hover:bg-accent, something you do without thinking. Styling it on keyboard focus only is focus-visible:ring-2, and there is a catch here you have almost certainly hit without knowing why. Write bare focus:ring-2 instead and the ring flashes on every mouse click. That looks noisy enough that someone deletes it, which quietly breaks the page for anyone navigating by keyboard. Styling a whole <form> red when any field inside it goes invalid was, in 2022, a useState plus a change handler plus a conditional class. In 2026 it is has-[input:invalid]:border-destructive and nothing else.
Each of those is a prefix you have typed dozens of times: hover:, focus-visible:, has-[…]:. What you have not met yet is what the colon-name underneath each one means to the browser: :hover, :focus-visible, :has(). What does the browser check when it decides an element is :hover? What is the difference between :focus and :focus-visible, and why is one of them a bug? How can a <form> know that something inside it is invalid without any JavaScript watching?
This lesson is the other half of a story you started a few chapters back. In the lesson on the variants that read DOM state, you learned the variant grammar: that hover:bg-primary is just the CSS rule &:hover { background: … } in a shorter syntax, and that variants come in families depending on whose state the selector reads. That lesson taught you how to write the prefix. This one teaches you what fires underneath it: the pseudo-classes themselves, as browser primitives. By the time you finish, you will be able to read any :state buried in a className, whether it is yours or one copied out of a component library, and know exactly what the browser is checking when it decides to apply it.
Two ideas carry the lesson, and neither is syntax. The first is :focus-visible, the focus reflex on every interactive element you will ever build, and the fix for the ugly-ring-on-click problem. The second is :has(), the selector that lets a parent react to its own contents, the one that 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”Start with the five pseudo-classes the browser sets from how the user is interacting with an element. You half-know most of them already, so the goal here is to pin down precisely which one fires when, because the differences between them are where the bugs come from.
Take the simplest ones first. :hover matches while the pointer is over the element. You know this one. On a touchscreen it quietly does nothing, since there is no pointer to hover, which has consequences we will come back 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 and lets go the moment you release. The canonical reach is active:scale-[0.98], a barely-perceptible shrink that makes a button feel physical. That scale-* is a transform, and the motion layer that animates the shrink is the subject of the next lesson. For now, just notice that :active is the state the shrink 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. Read that again, because the “or clicked it” is the whole trap. 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, which in practice means focus 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.
That last distinction is the one that decides whether your page works for keyboard users, so it is worth making concrete.
Every interactive element needs a visible focus indicator, the ring or outline that tells a keyboard user “you are here, this is what Enter will press.” Without it, tabbing through a page is navigating blind. The problem is that :focus fires on mouse clicks too, and a ring that pops up every single time you click a button looks like a glitch. Designers see it, dislike it, and reach for the worst possible fix: delete the focus styling entirely. Now the page looks clean for mouse users and is completely unusable for keyboard users, because the indicator they depend on is gone. That is an accessibility regression born from an aesthetic complaint about mouse clicks.
:focus-visible is the browser resolving that exact tension for you. It runs a small internal heuristic , asking whether this focus came from a keyboard or from a mouse, and only matches when a focus ring is genuinely warranted. Keyboard users and assistive technology get the ring, and mouse-clickers don’t. You get the clean look the designer wanted and the accessible behavior, with no trade-off to negotiate. There is essentially never a reason to reach for bare :focus over :focus-visible for a ring.
So here is the rule, and it carries straight from the borders-and-elevation lesson earlier in this chapter: focus-visible: is the default, and bare focus: is the bug. The stakes are higher than they look, because Preflight, the reset you met in the cascade chapter, strips the browser’s default focus outline to give you a blank canvas. Something has to put a visible focus state back, and that something is :focus-visible. Leave it out and you have an app no one can keyboard through.
You cannot feel this difference in a screenshot, since it only exists under live interaction, so try it directly. The exercise below has two buttons: one ringed with bare focus:, one with focus-visible:. Click each one with your mouse, then press Tab to move focus between them with the keyboard. Watch which button 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.
If you clicked the left button and saw a ring, then clicked the right one and saw nothing until you tabbed to it, that is the entire argument for :focus-visible in one gesture. The browser knew the difference between your mouse and your keyboard. You just had to ask the right pseudo-class.
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 that your theme defines, set up the way the earlier color and tokens lessons described. The live exercises in this lesson swap in literal colors like bg-slate-100 and ring-blue-500, because the sandbox can’t load your project’s theme; the shapes are identical, and only the color source differs. Each piece of the string owns a different concern. The ring-2 ring-ring/50 ring-offset-2 and the outline-none are the focus-ring geometry: what the ring is physically made of, and why it doesn’t shove the layout around. That geometry is the territory of the borders-and-elevation lesson. This lesson owns the other half, focus-visible: itself, the pseudo-class that decides when any of that paint appears.
:focus-within: when a parent reacts to a focused child
Section titled “:focus-within: when a parent reacts to a focused child”There is a second focus pseudo-class, and it points the other way.
: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 an input wrapped together in a bordered container, that lights up its border the moment the input inside it takes focus, drawing the eye to the field you are actually filling out.
This is the first time in the lesson a selector reaches upward, where an element styles itself because of something happening to a child. Hold onto that shape, because it is a small preview of the big idea two sections from now. Think of :focus-within as a single-purpose parent selector: it answers exactly one question, “is anything inside me focused?” :has(), which you will 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. So a wrapper that rings itself when its input is focused is focus-within:ring-2 focus-within:ring-ring. This is precisely the shadcn input-group and command-palette pattern, where the whole search bar glows as one unit when you click into the text field.
If you reach for this across a marked-up tree rather than on a direct wrapper, the variant is group-focus-within:, the same group mechanic from the DOM-state-variants lesson, reading :focus-within up the tree. Same pseudo-class, read from a parent.
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 illustration in the lesson of a pseudo-class deleting state outright. The 2022 way to highlight that row was const [isFocused, setIsFocused] = useState(false), plus an onFocus handler to set it true, plus an onBlur to set it false, plus a conditional class reading the boolean. That is four moving parts to track a fact the browser was already tracking. The 2026 way is one variant on the wrapper. The state was never anything 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 the 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 is the browser exposing a property of the control as something you can style.
:disabled matches when a control’s disabled property is set. The pattern to commit to muscle memory is disabled:opacity-50 disabled:pointer-events-none. The opacity-50 fades the control so it reads as inert (you met opacity-50 as the disabled-fade reflex back in the color lesson), 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 is straightforward, but the reach that actually matters reads it from somewhere else: the option card. You wrap a radio in a <label> that styles the whole card, and the card highlights when its radio is checked. That is :checked on the input being read by the card, which is a job for :has() in the next section. Flag it in your head: the input holds the :checked state, and the card reads it.
:invalid, :required, :read-only, and :placeholder-shown round out the set. They are the browser maintaining, respectively, whether a field’s value fails its constraints, whether it’s mandatory, whether it’s read-only, and whether 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.
Here is a boundary worth nailing down before it confuses you. :invalid here is a pseudo-class the browser sets that you can read for styling, and nothing more. It is not form validation. The whole machinery of validating a form, checking it on submit, showing error messages, running server-side checks, and deciding whether the form is allowed through, is the subject of a later chapter on forms. Right now, :invalid is purely a styling hook: the browser already knows the field is invalid, and you can paint based on that. What you do about it is a separate story.
Now wire the checked-card pattern yourself, because it is the one you’ll reach for 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 deleted a generation of JavaScript
Section titled “:has(): the parent selector that deleted a generation of JavaScript”For almost the entire history of CSS, selectors could only ever look down or sideways. You could style an element by its own state, or by an ancestor’s state, which is a child reading its parent. What you could never do was the reverse: style an element by what it contains. A parent could not react to its children. That single missing capability is why so much “the container changes when something inside it changes” logic had to be written in JavaScript, with state mirroring the DOM.
:has() is the selector that closed the gap. It selects an element by what it contains: el:has(.x) reads “an el that has a matching .x somewhere inside it.” For the first time, an element can react to its descendants. The Tailwind form is the has-[<selector>]: you just used, and you can lean on the one-line hook from the DOM-state-variants lesson here: has-[…]: is &:has(…), the same selector-wrapped-around-a-utility move as every other variant, just pointed inward.
What makes this matter is not the syntax but what each use deletes. Walk three of them and name the React that 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.
Each follows the same pattern, and it is worth stating plainly because it is 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 was holding the whole time. Every time you reach for :has(), you are deleting state you would otherwise have to keep in sync.
The before-and-after below makes that deletion concrete. Scrub through the three cases. The left side of each step is the 2022 React: state, a handler, a conditional class. The right side is the 2026 one-liner that replaces all of it. Watch the code shrink.
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 side keeps an isInvalid boolean, a handler that watches the input’s validity, and a cn() that branches on it. The 2026 right side is the same 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 that can’t fire in a non-live panel; the prose says what fires.)
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 side copies the radio’s checked into React state through an onChange, purely to add a fill. The right side 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 side threads a hasImage prop through a conditional class, often a whole second component. The right side is one shared string, p-6 has-[img]:p-0: the card looks at its own contents and decides.
A couple of things to know so you reach for :has() cleanly.
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 goes in the brackets. There is also group-has-[<selector>]:, which reads “an ancestor marked group that contains the match,” the same group mechanic from the DOM-state-variants lesson, for when the reacting element isn’t the direct container. The plain has-[…]: is the workhorse, and you’ll mostly just need to recognize the group- form when you see it.
On support: :has() has been Baseline since December 2023, so it works everywhere your SaaS will run, with no polyfill and no fallback to write. You reach for it without opening caniuse.
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 different layouts, and the only thing that varied was the content. No prop, no branch, no second component: the card looked at itself and decided.
Two things to watch for, while they’re fresh.
:has() chains. You can write has-[input:checked]:has-[.required]: to require two conditions at once, but readability falls off fast. Past two conditions, that line stops being something a teammate can read at a glance. The better move is to stop reaching deeper into the selector and instead set a single data-* attribute once, from wherever you’re computing the condition, and read that with the data-[…]: variant from the DOM-state-variants lesson. It’s a readability ceiling, not a capability limit: :has() can go deeper, you just shouldn’t make it.
:has() also does not reach inside shadow DOM . The internals of some native widgets, like the option list of a <select>, live in a sealed subtree your selectors can’t see into. When you need to style those, you lean on the form library (again, a later chapter on forms) rather than fighting :has() past where it can reach.
:not(): styling the exception
Section titled “:not(): styling the exception”The next pseudo-class is the one you reach for 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 the * is the state you’re negating: not-disabled:, not-first:, not-last:. The DOM-state-variants lesson named not-* in passing as part of the positional family; here it gets its own treatment, because the thing in the brackets can be any state, not just position.
The reach that justifies the whole pseudo-class is the disabled-hover trap. Picture a button with hover:bg-accent. Now disable it. In some browsers a disabled button still fires :hover, so you get the hover background painting on a control the user can’t actually use, which reads as broken. The fix is to 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 on that button as a set. not-disabled:hover:bg-accent is the live hover, applying only while the button is not disabled. disabled:opacity-50 disabled:pointer-events-none is the off state from earlier. Together they cover both cases: a real hover when the button works, a clean faded look when it doesn’t, and never a hover style on a dead control. That not-disabled:hover: guard is the single most useful :not() reach in app UI, so make it the reflex for any button that can be disabled.
The other use you’ll see 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 know that gap and divide-*, which you met in the layout chapter and the borders-and-elevation lesson, retired most of that work, so you’ll write not-last: far less than the old code you’ll read.
Here is a quick check on the trap. 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>The placeholder and selection pseudo-elements
Section titled “The placeholder and selection pseudo-elements”Now switch from states to sub-parts. Everything above this point has been a pseudo-class, a state the browser tracks on a real element. The two reaches in this section are pseudo-elements instead: 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’s prefix hides the colons, but the distinction is real, and it explains what these can and can’t do.
The first one fixes a bug you have almost certainly shipped. ::placeholder targets the faint hint text an input shows while it’s empty, and here is the catch: the placeholder does not inherit color the way you’d expect. Set your input’s text color and the placeholder ignores it, rendering at the input’s full text color. The result is a placeholder that looks exactly like a real typed-in value rather than a faint hint, so users try to “clear” text that isn’t there. The fix is placeholder:text-muted-foreground (the muted token from the color lesson) on every text input. It is the reflex on every one; without it your placeholders masquerade as content.
The second is pure polish. ::selection targets the highlight that appears when the user drags to select text. selection:bg-primary selection:text-primary-foreground brands that highlight in your colors instead of the OS default blue. It’s nice to have on a marketing surface and entirely optional.
There is a third you’ll see named but rarely touch: ::file-selector-button, the button inside an <input type="file">. It is worth knowing it exists, but you will rarely style it.
The placeholder bug is invisible until you watch it live. The input below has no placeholder styling, so notice how the placeholder reads like real, typed text. Add the fix so it recedes to a proper 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.)
The placeholder went from looking like content to looking like a hint, and the only change was naming the sub-part the markup never gave you a handle on. That’s what a pseudo-element is for.
Structural and link pseudo-classes: a quick recognition pass
Section titled “Structural and link pseudo-classes: a quick recognition pass”A handful of pseudo-classes remain that you should recognize without mistaking them for daily tools. This section is short on purpose, because most of what these used to do 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. The honest 2026 take is that they’re mostly retired, because gap (from the layout chapter) and divide-* (from the borders-and-elevation lesson) replaced the “put space or a line between siblings” work that drove most :nth-child reaches in the first place. The Tailwind forms first:, last:, nth-*:, and empty: still exist, and the one that genuinely earns its place is :empty: a list container with empty:hidden disappears when it has no children, which is the clean way to handle an empty state. The rest are recognition only.
Link pseudo-classes are :link (a link not yet visited) and :visited (one that has been). :visited exists, but it is privacy-locked: browsers let you change only a tiny set of properties on a visited link (color, background-color, border-color, and a few more) so that a page can’t probe your browsing history by measuring the computed style of a link. It comes up in long-form prose where links are content; in app UI, where links are navigation, you’ll rarely touch it.
One last name for the pile: :target matches the element whose id is currently in the URL’s hash, the basis for some hash-driven UI. Recognize it, but you won’t reach for it often.
The signal here is simple: these are real, but they are not your reflexes. gap and divide did the work they used to do.
Forcing element state in DevTools
Section titled “Forcing element state in DevTools”There is a practical problem hiding inside everything this lesson taught. 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 to read the Styles panel, the hover drops. You can’t hold the state and inspect it at the same time. :focus-visible is even harder to pin down by hand, since it depends on how focus arrived.
The fix is a button every browser’s DevTools has, and reaching for it is the reflex the moment a state-driven style misbehaves. Here’s the path in Chrome and Edge.
-
Open DevTools and select the element by right-clicking it and choosing Inspect, so it’s highlighted in the Elements panel.
-
In the Styles panel, click the
:hovbutton. Its tooltip reads Toggle Element State. -
Tick the state you want to pin:
:hover,:focus,:focus-visible,:active, or:target. The pseudo-class is now forced on and stays on, no matter where your mouse goes. -
Read the matched rules in the Styles panel 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 in the Styles panel and you can find whatever’s overriding or fighting your intended style. This is the practical other half of the lesson: the sections above taught you what fires each state, and this is how you freeze that state to debug it.
What you can now read
Section titled “What you can now read”Two threads ran through everything here. The first: a pseudo-class is the name of a fact the browser already tracks, whether that fact is interaction (:hover, :focus-visible, :active), control state (:disabled, :checked, :invalid), or structure (:has(), :empty). You don’t compute these facts; you read them, and you reach for useState only when no pseudo-class can match. The second: some of these facts live on the element itself, and some describe an ancestor reacting to a descendant. That second shape, :focus-within and :has(), is the one that deletes the most code.
Sort the lesson’s pseudo-classes along that second axis. Drop each one into the bucket for what it reacts to: a fact on the element itself, or a parent reacting to something inside it.
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 this lesson exists to make automatic.
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.These pseudo-classes flip styles the instant the browser’s state changes: a ring appears, a card lights up, a border turns red, with no transition between off and on. Next you’ll make those flips move with transitions and keyframe animation, the motion layer that turns 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.