State Selectors
🧷 State Selectors
Section titled “🧷 State Selectors”Pseudo-classes for interaction and validation
In States & Spirits, we interacted with a small Reader Preferences panel and watched it respond to:
- hover and active presses,
- keyboard focus,
- a checked checkbox,
- and valid vs invalid email input.
Now we open the spellbook and name the selectors behind those glows.
Here is the core markup we’re targeting:
<section class="prefs-panel"> <div class="panel-label">States & Spirits · UI Runes</div> <h2 class="panel-title">Reader Preferences</h2>
<div class="row"> <div class="row-label">Layout mode</div> <div class="mode-toggle" data-mode="compact" aria-label="Layout mode: compact"> <button type="button" class="mode-btn" data-mode="standard"> Standard </button> <button type="button" class="mode-btn" data-mode="compact"> Compact </button> </div> </div>
<div class="row"> <label class="breaking-row"> <input type="checkbox" name="breaking-only" checked> <span>Breaking news only</span> </label> <div class="row-label">Lights up when the spell is <code>:checked</code>.</div> </div>
<form class="alerts-form" novalidate> <span class="alerts-label">Email for alerts</span> <div class="alerts-input-wrapper"> <input class="alerts-input" type="email" name="alert-email" required placeholder="you@example.com" > <div class="status-pill"></div> </div> </form></section>🟦 Hover & Active — :hover and :active on Buttons
Section titled “🟦 Hover & Active — :hover and :active on Buttons”The layout mode buttons react to hover and press in a way that feels tactile.
.mode-btn { /* base styles */}
/* Hover: soften + lift slightly */.mode-btn:hover { color: var(--state-hover); transform: translateY(-1px);}
/* Active: pressed state on click */.mode-btn:active { color: var(--state-active); transform: translateY(0);}:hoveris pointer-based, great for subtle hints.:activeis the moment of pressing—useful for “click” feedback.
We still need a separate selector for keyboard focus.
🟪 Focus Rings — :focus-visible Instead of :focus
Section titled “🟪 Focus Rings — :focus-visible Instead of :focus”We want clear focus for keyboard users, without flashing rings on every mouse click.
.mode-btn:focus-visible { outline: 2px solid var(--state-focus); outline-offset: 3px; z-index: 2;}Compared to :focus:
:focus-visibleonly appears when the browser thinks the user needs that cue—
typically on keyboard navigation, not mouse clicks.
We can apply the same pattern to inputs:
.alerts-input:focus-visible { border-color: var(--state-focus); box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.5);}🟧 Checked Parent — :has(input:checked)
Section titled “🟧 Checked Parent — :has(input:checked)”The “Breaking news only” pill highlights the entire label when its checkbox is checked.
.breaking-row { /* neutral pill styles */}
/* Parent reacts when the child checkbox is :checked */.breaking-row:has(input[type="checkbox"]:checked) { border-color: var(--state-active); box-shadow: 0 0 0 1px rgba(249, 115, 22, 0.5); background: radial-gradient(circle at left, rgba(249, 115, 22, 0.18), rgba(15, 23, 42, 0.95)); color: var(--state-active);}Without :has(), we’d typically need to:
- add a “checked” class on the parent via JavaScript, and
- wire that class to styling instead.
Here, :has() lets pure CSS say:
“Style this
.breaking-rowwhen it has a checked input inside.”
🟩 / 🟥 Valid vs Invalid — :valid, :invalid, :placeholder-shown
Section titled “🟩 / 🟥 Valid vs Invalid — :valid, :invalid, :placeholder-shown”The email input changes mood based on whether its current value parses as a valid email.
.alerts-input { /* neutral styles */ border: 1px solid rgba(71, 85, 105, 0.95);}
/* Valid and not just the placeholder */.alerts-input:valid:not(:placeholder-shown) { border-color: var(--state-valid); box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.4);}
/* Invalid and not just empty */.alerts-input:invalid:not(:placeholder-shown) { border-color: var(--state-invalid); box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.6);}The important ideas:
- We combine
:valid/:invalidwith:not(:placeholder-shown)so that:- the empty state is neutral,
- we only show error after the user starts typing.
The status pill to the right is updated purely with sibling selectors:
.status-pill { /* neutral pill */}
/* Invalid state */.alerts-input:invalid:not(:placeholder-shown) ~ .status-pill { background: rgba(248, 113, 113, 0.18); color: var(--state-invalid); border: 1px solid rgba(248, 113, 113, 0.7);}
/* Valid state */.alerts-input:valid:not(:placeholder-shown) ~ .status-pill { background: rgba(34, 197, 94, 0.16); color: var(--state-valid); border: 1px solid rgba(34, 197, 94, 0.7);}No JavaScript needed—just careful use of ~ (general sibling) and pseudo-classes.
🧠 Patterns to Steal
Section titled “🧠 Patterns to Steal”From this one small panel we get several reusable patterns:
- Use
:hoverand:activefor pointer-based feedback. - Use
:focus-visibleso keyboard users have clear focus without “outline spam.” - Use
:has()for parent styling when a child is:checkedor:focus(with performance awareness). - Combine
:valid/:invalidwith:placeholder-shownto avoid punishing untouched fields. - Use sibling selectors (
+/~) to update adjacent UI elements based on an input’s state.
These are the core State & Spirit runes you’ll reach for again and again
as you design responsive, accessible, and expressive UI.
Next up: Arcane Attributes, where we stop looking at state and start reading the metadata etched into the HTML itself.