Interaction States
Tokens for hover, press, focus, and disabled states. Composable across surfaces via overlay primitives + per-brand state-pair semantics.
Every interactive component (button, card, tag, chip, link) uses the same interaction-state vocabulary. Two layers compose:
- Composable overlay primitives — transparent overlays that work on any surface (brand, neutral, status, service-line) without needing a unique hover token per color.
- Brand state pairs — solid
-hover/-pressedSemantic tokens on brand-colored surfaces (filled brand buttons) where an overlay would be visually weak.
Overlay primitives
These four tokens are mode-invariant — they resolve to the same value in light and dark.
| Token | Value | Purpose |
|---|---|---|
--state-hover-overlay | rgba(0, 0, 0, 0.04) | Subtle hover overlay on any surface |
--state-pressed-overlay | rgba(0, 0, 0, 0.08) | Stronger overlay for :active / press |
--state-focus | currentColor | Keyboard focus ring color — resolves to the element's own text color at use-site |
--state-disabled-opacity | 0.5 | Opacity for disabled elements that have no fill of their own |
The overlays are a fixed black rgba in both modes — there is no white-overlay override for dark surfaces today, so a hover/press overlay reads faintly on very dark backgrounds. Wire a dark-mode override if a dark surface needs a stronger cue.
Why overlays? A transparent overlay works on ANY background — brand, surface-secondary, status-positive, service-line — without needing a unique hover token per color. One token handles every context. Solid -hover tokens only exist where the brand-fill cases need them.
Component usage pattern
/* Hover — composable overlay on any background */
.bds-component:hover {
box-shadow: inset 0 0 0 999px var(--state-hover-overlay);
}
/* Press — stronger overlay */
.bds-component:active {
box-shadow: inset 0 0 0 999px var(--state-pressed-overlay);
}
/* Focus — reuses existing border-width token; offset is fixed at 2px for a11y */
.bds-component:focus-visible {
outline: var(--border-width-lg) solid var(--state-focus);
outline-offset: 2px;
}
/* Brand button hover — solid primitive (not overlay) since fill needs a real shift */
.bds-button--primary:hover {
background-color: var(--background-brand-primary-hover);
}
/* Disabled — only for a component with no fill of its own. See the callout. */
.bds-component--disabled {
opacity: var(--state-disabled-opacity);
cursor: not-allowed;
pointer-events: none;
}The fade is not the universal disabled treatment. It applies only to a control with no fill of its own, where the page or an ancestor surface shows through. A control that paints its own fill swaps tokens instead: --background-disabled, --text-disabled, --border-disabled. Button, FilterButton, and FilterToggle are the fill-bearing cases today.
The reason is measurable, not stylistic. opacity composites the label and the fill toward the same backdrop, so a filled control's contrast collapses as it fades — a brand-filled button lands at 1.68:1 in light mode against a 3:1 floor it clears at 5.22:1 with the swap. Pick by asking whether the control has a fill, not by counting which treatment is more common.
Never set a disabled opacity literal — npm run lint-disabled-fade fails the build on one, because the visual suite cannot catch it (disabled is an argTypes control, so it has no baseline to move). Every disabled rule reads --state-disabled-opacity so one token governs the whole cohort and the contrast gate can score it. 0.5 is the measured floor: below it, a faded --text-primary label misses 3:1 in light mode. Do not pair the fade with a --text-muted label either — that stacks two reductions on the same text and no opacity value rescues it in dark mode.
Brand state pairs
For brand-colored surfaces (filled primary buttons, brand backgrounds), the overlay approach gets visually weak — the inset overlay against a vibrant brand color doesn't read as a strong-enough interaction signal. These surfaces use solid state-pair Semantic tokens that resolve to deeper or lighter stops of the 11-step ramp.
What the shipped Brik theme resolves to, by stop:
| Token | Light mode | Dark mode |
|---|---|---|
--background-brand-primary | --color-poppy-500 | --color-poppy-500 |
--background-brand-primary-hover | --color-poppy-700 | --color-poppy-700 |
--background-brand-primary-pressed | --color-poppy-800 | --color-poppy-800 |
--surface-brand-primary | --color-poppy-500 | --color-grayscale-950 |
--surface-brand-primary-hover | --color-poppy-700 | --color-grayscale-800 |
--surface-brand-primary-pressed | --color-poppy-800 | --color-grayscale-700 |
Read the direction, not a fixed step count:
- The brand fill deepens on interaction in both modes. Base
500→ hover700→ pressed800. Hover is two stops deeper than the base, press three — the ramp is denser than the interaction ladder, so don't assume "one stop per state". --surface-brand-primaryinverts in dark mode. It resolves to near-black grayscale, so it lightens on interaction (950→800→700) rather than deepening. A dark brand surface has nowhere deeper to go.- A Brand Kit picks its own stops. The numbers above are Brik's; the contract is the direction and the visible delta, not these specific stops.
Historically this table read "one step deeper / one step lighter" against the 6-step ladder. Under 11 stops that phrasing is unusable — the old anchors are unevenly spaced, and the dark-mode fill never lightened in the first place. Count stops against dist/tokens.css, which is the authority.
State-pair siblings are required when a Brand Kit overrides --background-brand-primary or --surface-brand-primary. Override the base color without pairing the -hover / -pressed siblings, and the canonical default leaks through on interaction. The cleanup in brik-bds#710 retired exactly this pattern of bug.
Focus ring rules
- Width uses
--border-width-lg— no new dimension token. Mode picks (data-mode-borderwidth) propagate automatically. - Offset is hardcoded
2pxin CSS — accessibility fixed value, not themeable. - Color is
--state-focus, which resolves tocurrentColor— the ring inherits the element's own text color at use-site rather than a fixed brand token.
Overlay vs solid
Walk this top-down when implementing a component's hover/press:
- Is the resting background a brand color or status color (filled buttons, brand badges)?
- Yes → use solid
-hover/-pressedSemantic tokens (--background-brand-primary-hover, etc.).
- Yes → use solid
- Is the resting background a neutral surface (cards, list items, ghost buttons, links on
--surface-primary)?- Yes → use overlay primitives (
--state-hover-overlay,--state-pressed-overlay).
- Yes → use overlay primitives (
- Is the component a link or text-button (no fill, color shift only)?
- Use the overlay primitive OR shift the text color deeper in the ramp (e.g.,
--text-brand-primary→--color-poppy-700on hover). Don't compose both.
- Use the overlay primitive OR shift the text color deeper in the ramp (e.g.,
Related
- Token Anatomy — the four-tier abstraction these state tokens sit at (Semantic Tier)
- The Cascade — how state tokens compose with Modes and brand overrides
- Color — the Primitive ramps the brand state pairs draw from
- Client Themes — Brand Kit override matrix (state pairs are required when brand colors change)