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 (
#0c52d9instead 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:
| State | Token | Resolves 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:
| State | Success token | Resolves 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:
- Cognitive consistency. Users learn "the brand-blue glow means I can interact with this." That cue should be stable across every component.
- 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:
- 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). - 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 time | What happens visually |
|---|---|
| Render | bg-error (error-600), white text, subtle inset bottom shadow |
| Mouse hover | bg fades to error-hover (error-700) over 120ms |
| Tab focus (keyboard) | 3px brand-blue ring with 2px offset appears |
| Mouse press | bg jumps to error-active (error-800), button scales to 0.96 |
| Release | bg returns to error-hover (still hovered), scale returns to 1 |
| Mouse out | bg returns to error (error-600) |
Set disabled | Element opacity to 50%, pointer events lock, scale + hover disabled |
Set loading | Same 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.