Skip to content
Foundations

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

Hover, focus and press modify the surface a component already has. They do not swap it for a neutral one. And the combined state is written down as its own rule, so neither specificity nor source order gets to decide it by accident.
The middle row is selected. Point at it: it stays in accent and deepens, rather than flipping to the neutral hover grey. Point at the others and they take the ordinary hover.
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.

SelectorSpecificityWins?
.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
Real numbers from GdNavRailItem before the fix. The active rule was declared LATER in the file and still lost — source order was never going to save it.
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-hover is 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.

Navigate

Esc