All docs
Everyone· 5 min

States & interaction

Every hover, focus, active state maps to a deliberate palette step. No random darken filters.

The single most common way a design system feels "cheap" is its interactive states. A button that's been carefully designed at rest can fall apart on hover — usually because someone slapped a :hover { opacity: 0.8 } on it instead of designing the hover state.

This doc is the rule book for how interactive states are designed across UpNext.

The principle: every state is a deliberate palette step

When a button is hovered, it doesn't darken via filter or opacity. It moves to a specific, adjacent step in the palette ramp. That step was chosen intentionally and named in the semantic layer.

Why this matters:

  • Filter-based darkens (brightness(0.9), opacity: 0.8) look murky because they shift saturation and lightness in ways the eye reads as "muddy" rather than "darker."
  • Random hex tweaks (#0c52d9 instead of #1464FF) get lost — nobody remembers what the hover color was supposed to be, and the next button uses a different one.
  • A named token step (--primary-hover → primary-600) is auditable, consistent, and remixable. If you ever need to change how hover feels, you change one token and every component updates.

The state-to-step map

For the primary action, here's the full mapping:

StateTokenResolves to
Resting--primary--primary-500
Hover--primary-hover--primary-600
Active/pressed--primary-active--primary-700
Disabled--primary + opacity: 50%(visual treatment, not a different color)
Focus ring--ring--primary-500
Tinted bg (ghost hover)--primary-subtle--primary-50

Read this from top to bottom — each state shifts one notch further down the ramp. That's the rhythm: resting → hover one step down → active one more step down. Predictable, palette-aligned.

The same pattern applies to every status color:

StateSuccess tokenResolves to
Resting--success--success-500
Hover--success-hover--success-600
Active--success-active--success-700

…and so on for error, warning, info.

Focus rings are always brand

We don't tint focus rings to match the variant. A destructive button doesn't have a red focus ring. A success button doesn't have a green one. Every interactive element uses --ring, which is --primary-500.

Two reasons:

  1. Cognitive consistency. Users learn "the brand-blue glow means I can interact with this." That cue should be stable across every component.
  2. Accessibility. A consistent ring color is easier to design contrast for. We can guarantee 3:1 contrast against every surface in the system. If rings shifted per variant, we'd have to audit each one separately.

The exception: aria-invalid controls get an error-tinted ring (--error/30) to make the validation signal unmissable. That's a different signal — "this is wrong" — not "this is interactive."

Disabled state

Disabled = opacity: 50% on the entire element, plus pointer-events: none so it can't be interacted with. We don't shift to a separate "disabled gray" because:

  • Opacity preserves the relationship to the element's resting color — a disabled primary button still reads as "the primary button, but inactive" rather than "some other gray thing."
  • Maintaining color relationships is what keeps the page composition intact when things get disabled mid-flow.

The only exception is form inputs, which get bg-muted when disabled — the slight background tint signals "this isn't an empty input, it's a locked one."

Loading state

Loading is disabled + a spinner. We don't have a separate "loading color." The button stays the same, gets an inline spinner via data-icon="inline-start", and is pointer-events-locked. The label can change ("Save" → "Saving…") at the consumer's discretion.

The loading prop on <Button> handles all of this automatically — pass loading={isSaving} and the wrapper takes care of the spinner, the disabled state, and the cursor.

Transitions

Every interactive transition is 120ms with the curve cubic-bezier(0.2, 0, 0, 1) (a "snap-to-resting" ease).

We're strict about two things:

  1. Specific properties only. Never transition: all. The transition declares exactly what it animates — transition-[background-color,border-color,color,scale]. This avoids accidental animations on unrelated properties (e.g. layout shifts when content size changes triggers a janky transition you didn't intend).
  2. One curve. All hover/active transitions use the same easing. Mixing curves makes the system feel inconsistent in a way users sense but can't articulate.

Longer transitions (≥200ms) are reserved for enter/exit animations on overlays. Those use cubic-bezier(0.4, 0, 0.2, 1) — a more symmetric ease that reads as "appearing" rather than "snapping."

The tactile press

Buttons (and other actionable surfaces) scale to 0.96 on :active. This gives the press a small physical feedback that makes the interaction feel real.

scale-[0.96] is the right value. Smaller (0.94, 0.92) feels exaggerated, like the button shrunk too far. Larger (0.98, 0.99) is too subtle to register. 0.96 lands in the spot where you feel it without thinking about it.

Link-variant buttons skip the scale — links shouldn't feel like buttons. Same for tabs and other navigation: the indicator moves, but the element doesn't compress.

Putting it together: a destructive button's full lifecycle

<Button variant="destructive">Delete project</Button>
Moment in timeWhat happens visually
Renderbg-error (error-600), white text, subtle inset bottom shadow
Mouse hoverbg fades to error-hover (error-700) over 120ms
Tab focus (keyboard)3px brand-blue ring with 2px offset appears
Mouse pressbg jumps to error-active (error-800), button scales to 0.96
Releasebg returns to error-hover (still hovered), scale returns to 1
Mouse outbg returns to error (error-600)
Set disabledElement opacity to 50%, pointer events lock, scale + hover disabled
Set loadingSame as disabled, plus a spinner appears via data-icon="inline-start"

Every one of those state transitions is a named token, not a magic value. That's the system.