Brik Design System
Foundation

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:

  1. Composable overlay primitives — transparent overlays that work on any surface (brand, neutral, status, service-line) without needing a unique hover token per color.
  2. Brand state pairs — solid -hover / -pressed Semantic 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.

TokenValuePurpose
--state-hover-overlayrgba(0, 0, 0, 0.04)Subtle hover overlay on any surface
--state-pressed-overlayrgba(0, 0, 0, 0.08)Stronger overlay for :active / press
--state-focuscurrentColorKeyboard focus ring color — resolves to the element's own text color at use-site
--state-disabled-opacity0.5Opacity 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:

TokenLight modeDark 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 → hover 700 → pressed 800. 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-primary inverts in dark mode. It resolves to near-black grayscale, so it lightens on interaction (950800700) 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 2px in CSS — accessibility fixed value, not themeable.
  • Color is --state-focus, which resolves to currentColor — 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:

  1. Is the resting background a brand color or status color (filled buttons, brand badges)?
    • Yes → use solid -hover / -pressed Semantic tokens (--background-brand-primary-hover, etc.).
  2. 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).
  3. 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-700 on hover). Don't compose both.
  • 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)

On this page

💬