Button and Interactive Element States

SkillDev tools

Every interactive element needs a complete set of visual states — rest, hover, active/pressed, focus, disabled, and loading. States should be derived algorithmically from the base colour, not chosen arbitrarily. Use when designing buttons, links, inputs, or any clickable component.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Button and Interactive Element States skill

What this skill tells your AI

The instructions your AI receives, as published by dembrandt/dembrandt-skills in skills/button-states/SKILL.md and read by ahel’s review.

Every interactive component must have a complete, visually distinct state for each interaction mode. Missing or ambiguous states make the UI feel unfinished and reduce user confidence.

The Six States

StateTriggerVisual signal
RestDefaultBase colour, cursor: pointer
HoverMouse overSlightly darker, subtle background shift
Active / PressedMouse down / tapNoticeably darker, slight scale-down
FocusKeyboard navigationVisible focus ring, no change to fill
DisabledNot availableLow contrast, cursor: not-allowed, no interaction
LoadingAsync action in progressSpinner or pulse, non-interactive

Deriving State Colours Algorithmically

State colours are not chosen independently — they are derived from the base colour by adjusting lightness in HSL. This guarantees coherence across the entire palette.

base:     hsl(H, S%, L%)
hover:    hsl(H, S%, L% - 8%)    ← darken 8%
active:   hsl(H, S%, L% - 14%)   ← darken 14%

Example: primary button #635BFF (hsl 243, 100%, 68%)

.btn-primary {
  background: hsl(243, 100%, 68%);       /* rest    #635BFF */
}
.btn-primary:hover {
  background: hsl(243, 100%, 60%);       /* hover   #4A40FF */
}
.btn-primary:active {
  background: hsl(243, 100%, 54%);       /* active  #3429FF */
}

For light buttons on dark backgrounds, invert the logic — lighten on hover instead of darkening.

Secondary / outlined buttons

.btn-secondary {
  background: transparent;
  border: 1px solid var(--color-border);
  color: var(--color-text);
}
.btn-secondary:hover {
  background: var(--color-grey-100);     /* subtle fill */
  border-color: var(--color-grey-300);
}
.btn-secondary:active {
  background: var(--color-grey-200);
}

Focus State

Focus is a keyboard navigation requirement (WCAG 2.2). It must be visible and must not rely on the hover style alone — keyboard users do not trigger hover.

.btn:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 3px;
}
  • Use outline, not box-shadow, for focus rings — outline respects border-radius in modern browsers and does not affect layout
  • outline-offset: 2–4px gives the ring breathing room from the component edge
  • Never use outline: none without a replacement focus style

Disabled State

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.4;
  cursor: not-allowed;
  pointer-events: none;
}
  • Disabled elements are exempt from WCAG contrast requirements — low opacity is correct and intentional
  • Use pointer-events: none to prevent click events even if JS is bypassed
  • Do not change the shape or size of a disabled button — only colour and cursor change

Loading State

When a button triggers an async action, replace the label with a spinner and prevent re-submission.

.btn--loading {
  pointer-events: none;
  cursor: wait;
  opacity: 0.7;
}
  • Keep the button width stable during loading — avoid layout shift when label is replaced by spinner
  • Return to rest state on completion (success or error)
  • For long-running operations, pair with a status message — a spinner alone does not tell the user what is happening

Scale on Active (Optional)

A subtle scale-down on press adds physical feedback — borrowed from Disney's squash principle.

.btn:active {
  transform: scale(0.97);
  transition: transform 80ms ease-out;
}

Keep the scale value between 0.95–0.98. Below 0.95 feels like the button is breaking.

Complete Button CSS Reference

.btn {
  cursor: pointer;
  background: var(--color-primary);
  color: white;
  border-radius: var(--radius-button);
  padding: var(--component-padding-y-md) var(--component-padding-x-md);
  height: var(--component-height-md);
  border: none;
  transition: background 120ms ease-out, transform 80ms ease-out;
}

.btn:hover           { background: var(--color-primary-hover); }
.btn:active          { background: var(--color-primary-active); transform: scale(0.97); }
.btn:focus-visible   { outline: 2px solid var(--color-primary); outline-offset: 3px; }
.btn:disabled        { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.btn.btn--loading    { opacity: 0.7; cursor: wait; pointer-events: none; }

Review Checklist

  • Does every interactive element have all six states defined?
  • Are hover and active colours derived from the base by lightness adjustment (not chosen arbitrarily)?
  • Is focus state visible and using outline (not removed)?
  • Is disabled state low-opacity with cursor: not-allowed?
  • Does loading state prevent re-submission?
  • Are transition durations 80–150ms — not instant, not slow?
  • Does cursor: pointer appear on all interactive elements at rest?

Signals

GitHub stars
54
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
button-states
Source
github.com/dembrandt/dembrandt-skills