$npx -y skills add dembrandt/dembrandt-skills --skill button-statesEvery 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 componen
| 1 | # Button and Interactive Element States |
| 2 | |
| 3 | 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. |
| 4 | |
| 5 | ## The Six States |
| 6 | |
| 7 | | State | Trigger | Visual signal | |
| 8 | |---|---|---| |
| 9 | | **Rest** | Default | Base colour, cursor: pointer | |
| 10 | | **Hover** | Mouse over | Slightly darker, subtle background shift | |
| 11 | | **Active / Pressed** | Mouse down / tap | Noticeably darker, slight scale-down | |
| 12 | | **Focus** | Keyboard navigation | Visible focus ring, no change to fill | |
| 13 | | **Disabled** | Not available | Low contrast, cursor: not-allowed, no interaction | |
| 14 | | **Loading** | Async action in progress | Spinner or pulse, non-interactive | |
| 15 | |
| 16 | ## Deriving State Colours Algorithmically |
| 17 | |
| 18 | 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. |
| 19 | |
| 20 | ``` |
| 21 | base: hsl(H, S%, L%) |
| 22 | hover: hsl(H, S%, L% - 8%) ← darken 8% |
| 23 | active: hsl(H, S%, L% - 14%) ← darken 14% |
| 24 | ``` |
| 25 | |
| 26 | ### Example: primary button `#635BFF` (hsl 243, 100%, 68%) |
| 27 | |
| 28 | ```css |
| 29 | .btn-primary { |
| 30 | background: hsl(243, 100%, 68%); /* rest #635BFF */ |
| 31 | } |
| 32 | .btn-primary:hover { |
| 33 | background: hsl(243, 100%, 60%); /* hover #4A40FF */ |
| 34 | } |
| 35 | .btn-primary:active { |
| 36 | background: hsl(243, 100%, 54%); /* active #3429FF */ |
| 37 | } |
| 38 | ``` |
| 39 | |
| 40 | For light buttons on dark backgrounds, invert the logic — lighten on hover instead of darkening. |
| 41 | |
| 42 | ### Secondary / outlined buttons |
| 43 | |
| 44 | ```css |
| 45 | .btn-secondary { |
| 46 | background: transparent; |
| 47 | border: 1px solid var(--color-border); |
| 48 | color: var(--color-text); |
| 49 | } |
| 50 | .btn-secondary:hover { |
| 51 | background: var(--color-grey-100); /* subtle fill */ |
| 52 | border-color: var(--color-grey-300); |
| 53 | } |
| 54 | .btn-secondary:active { |
| 55 | background: var(--color-grey-200); |
| 56 | } |
| 57 | ``` |
| 58 | |
| 59 | ## Focus State |
| 60 | |
| 61 | 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. |
| 62 | |
| 63 | ```css |
| 64 | .btn:focus-visible { |
| 65 | outline: 2px solid var(--color-primary); |
| 66 | outline-offset: 3px; |
| 67 | } |
| 68 | ``` |
| 69 | |
| 70 | - Use `outline`, not `box-shadow`, for focus rings — `outline` respects `border-radius` in modern browsers and does not affect layout |
| 71 | - `outline-offset: 2–4px` gives the ring breathing room from the component edge |
| 72 | - Never use `outline: none` without a replacement focus style |
| 73 | |
| 74 | ## Disabled State |
| 75 | |
| 76 | ```css |
| 77 | .btn:disabled, |
| 78 | .btn[aria-disabled="true"] { |
| 79 | opacity: 0.4; |
| 80 | cursor: not-allowed; |
| 81 | pointer-events: none; |
| 82 | } |
| 83 | ``` |
| 84 | |
| 85 | - Disabled elements are exempt from WCAG contrast requirements — low opacity is correct and intentional |
| 86 | - Use `pointer-events: none` to prevent click events even if JS is bypassed |
| 87 | - Do not change the shape or size of a disabled button — only colour and cursor change |
| 88 | |
| 89 | ## Loading State |
| 90 | |
| 91 | When a button triggers an async action, replace the label with a spinner and prevent re-submission. |
| 92 | |
| 93 | ```css |
| 94 | .btn--loading { |
| 95 | pointer-events: none; |
| 96 | cursor: wait; |
| 97 | opacity: 0.7; |
| 98 | } |
| 99 | ``` |
| 100 | |
| 101 | - Keep the button width stable during loading — avoid layout shift when label is replaced by spinner |
| 102 | - Return to rest state on completion (success or error) |
| 103 | - For long-running operations, pair with a status message — a spinner alone does not tell the user what is happening |
| 104 | |
| 105 | ## Scale on Active (Optional) |
| 106 | |
| 107 | A subtle scale-down on press adds physical feedback — borrowed from Disney's squash principle. |
| 108 | |
| 109 | ```css |
| 110 | .btn:active { |
| 111 | transform: scale(0.97); |
| 112 | transition: transform 80ms ease-out; |
| 113 | } |
| 114 | ``` |
| 115 | |
| 116 | Keep the scale value between `0.95–0.98`. Below `0.95` feels like the button is breaking. |
| 117 | |
| 118 | ## Complete Button CSS Reference |
| 119 | |
| 120 | ```css |
| 121 | .btn { |
| 122 | cursor: pointer; |
| 123 | background: var(--color-primary); |
| 124 | color: white; |
| 125 | border-radius: var(--radius-button); |
| 126 | padding: var(--compone |