Interaction states
Transient states and persistent states are different claims, and they must compose. Pointing at the thing you already selected must never make it stop looking selected — and the combination is always declared, never left to whichever of specificity or source order happens to win.
Two kinds of state
Transient
hover · focus · focus-visible · active (pressed)
Says: You are pointing at this, right now.
Lasts: As long as the pointer or the keyboard is there. Gone the moment it leaves.
Persistent
is-active · is-selected · is-current · aria-current
Says: This is where you are, or what you have chosen.
Lasts: Until the application says otherwise. Nothing the pointer does changes it.
The two answer different questions, so one cannot stand in for the other. A row that is selected and hovered is in both states and has to look like it: still selected, and visibly under the pointer.
The rule
Information: A transient state never replaces a persistent one — it deepens it
Code
/* the persistent state */
.gd-row.is-selected { background: var(--gd-surface-selected); }
/* the transient one */
.gd-row.is-interactive:hover { background: var(--gd-surface-hover); }
/* BOTH — declared, not inferred */
.gd-row.is-interactive.is-selected:hover {
background: color-mix(in oklab, var(--gd-surface-selected), var(--gd-accent-ink) 8%);
}Why this goes wrong so quietly
Both rules are individually correct. The CSS is valid, the tokens are real, nothing throws and no test fails. The defect only appears when you point at the selected thing — which is the one interaction nobody performs while building the selected state.
Specificity does not care what you meant
The usual cause is a hover selector that is accidentally heavier than the state selector. :not() is the common culprit: it contributes its argument's specificity, so a hover rule guarded against disabled items quietly outweighs the state it sits beside.
| Selector | Specificity | Wins? |
|---|---|---|
| .gd-rail-item:hover:not(:disabled) .…__indicator | (0,4,0) | yes |
| .gd-rail-item.is-active .…__indicator | (0,3,0) | no |
| .gd-rail-item.is-active:hover:not(:disabled) .…__indicator | (0,5,0) | the fix |
Code
.gd-rail-item:hover:not(:disabled) .gd-rail-item__indicator /* (0,4,0) */
.gd-rail-item.is-active .gd-rail-item__indicator /* (0,3,0) */Equal specificity is not a decision
When the two rules tie, source order decides — and whichever way it falls, nobody chose it. If the state rule is later, the selected item is the one thing in the list that does not respond to the pointer. If the hover rule is later, selection disappears under the cursor. Both are the same defect wearing different clothes, and both are fixed the same way: declare the combination.
How much to deepen
Mix the persistent state's own colour toward the accent ink and stop early. Deepening the surface and keeping the thing on top of it legible pull in opposite directions, so the step is bounded by the contrast of whatever sits on it.
- 8% is the house step, in
oklab. It is the largest mix that keeps an icon or a label past 4.5:1 in both themes on the surfaces this system actually uses. - Measure the content, not just the surface. A deeper pill that costs its icon 0.6 of contrast has traded the wrong thing — the hover is a hint, the icon is the message.
- Never reach for a neutral.
--gd-surface-hoveris for items in no persistent state. On a selected item it erases the very thing being reported. - Same value on both sides is not a defect. If hover and the state set a property to the same thing, they collide on paper and are identical on screen — leave it alone.
It is enforced
pnpm check:states compares every :hover rule against every persistent-state rule on the same element and fails the build when the transient one can win with nothing declaring the combination — by specificity or by source order.
It exempts the two cases where a collision is not a defect: the same property set to the same value, and an existing rule that carries both states. A guard that reports things that are fine is a guard people learn to skip.