Buttons & Controls (Desktop)
Hierarchy first, styling second
Before styling a single button, decide how many hierarchy levels this screen actually needs. Most screens need exactly three:
| Level |
Use for |
Visual weight |
| Primary |
The one action you want taken |
Filled, brand color, highest contrast |
| Secondary |
Alternative or supporting actions |
Outlined or tonal (soft-filled), medium contrast |
| Tertiary / ghost |
Low-stakes or frequent actions (toolbar icons, "cancel") |
Text-only or icon-only, no border, lowest contrast |
One primary button per view, maximum. If a screen wants two visually loud actions, that's a sign the screen is doing two jobs — split it, or demote one to secondary. This is the single fastest fix for the "uniform button weight" AI tell.
Destructive actions get their own color, not just a secondary style — a red/danger accent, always with a confirmation step for anything irreversible (see desktop-ui-surfaces-overlays for confirmation dialogs).
Sizing
Use exactly 3 sizes, tied to the spacing scale from foundations:
sm — 28–32px tall, dense toolbars, inline table actions
md — 36–40px tall, the default for almost everything
lg — 44–48px tall, rare — a single hero action, not a whole screen of them
Horizontal padding should be roughly 1.5× the control's font size on each side. Icon-only buttons should be square (equal width/height) at the same three sizes, with the icon centered and sized to about 60% of the control's height.
Minimum hit target: 24×24px even if the visible control is smaller. Toolbar icon buttons especially — pad the clickable area beyond the drawn icon rather than shrinking the target to match tight visual spacing.
States — every control needs all of these, not just default and hover
| State |
What changes |
Notes |
| Default (rest) |
Base style |
— |
| Hover |
Subtle background/border shift, cursor becomes pointer |
Gate behind (hover: hover) and (pointer: fine) so touch/trackpad taps don't get stuck in a fake hover state |
| Active / pressed |
transform: scale(0.97) plus a slightly darker/tonal shift, ~120–160ms ease-out |
This is the single highest-leverage "feels responsive" cue in the whole app — see desktop-ui-motion |
| Focus (keyboard) |
Visible focus ring, distinct from hover |
Never remove focus rings without replacing them — desktop power users navigate by keyboard constantly |
| Disabled |
Reduced opacity (~40–50%) AND cursor: not-allowed; never rely on opacity alone since it reads as "loading" as easily as "disabled" |
Disabled controls should still be legible enough to read their label |
| Loading |
Spinner or progress replaces the label/icon, control stays the same size (no layout shift), stays disabled |
Never let a button resize itself between idle and loading — reserve the space |
Toggles, checkboxes, radio buttons, segmented controls
- Checkbox: multi-select within a set, independent options.
- Radio: single-select within a visible set of 2–6 options where all options should be visible at once.
- Switch/toggle: a single on/off setting that takes effect immediately (not "submit to apply"). Never use a toggle for something that requires a save action — that's a checkbox or a select.
- Segmented control: single-select where seeing all options side-by-side, and switching between them instantly, matters more than saving space (e.g. view mode: List / Grid / Timeline).
All four need the same state coverage as buttons above, plus a clear, animated (not instant-snap) transition for the checked/unchecked state — a switch thumb should slide, not teleport (120–180ms, see desktop-ui-motion).
Icon buttons specifically
- Never ship an icon-only button without a tooltip (see
desktop-ui-text-inputs/desktop-ui-feedback-messaging for tooltip timing) unless the icon is truly universal (close ×, and even that benefits from one).
- Keep icon buttons visually consistent with the icon rules in
desktop-ui-icons — same stroke weight, same optical size, across the whole toolbar.
- A row of icon buttons should have even spacing at the token scale (usually 4 or 8px gaps), not eyeballed.
Review format
When reviewing existing button/control code, use this table, one row per issue:
| Before |
After |
Why |
transition: all 0.3s on a button |
transition: transform 150ms ease-out, background-color 150ms ease-out |
Animating all is imprecise and can catch properties you didn't intend to transition |
No :active state |
transform: scale(0.97) on press |
Missing press feedback is the fastest way for a control to feel dead |
Disabled button at full color, just pointer-events: none |
Reduced opacity + cursor: not-allowed + still pointer-events: none |
Disabled must be visually obvious, not just functionally inert |
| Three buttons, all filled brand color, same size |
One filled (primary), two outlined/ghost (secondary/tertiary) |
Establishes which action the user should actually take |
1---2name: desktop-ui-buttons-controls3description: Use whenever designing, building, or reviewing buttons, icon buttons, toggles, switches, checkboxes, radio buttons, segmented controls, or any other clickable/pressable control in a desktop app. Depends on desktop-ui-foundations for tokens — read that first if it hasn't been read this session. Trigger on "design a button," "this button looks off," "add a toggle/switch," "button states," or any control/interaction-hierarchy question.4---56# Buttons & Controls (Desktop)78## Hierarchy first, styling second910Before styling a single button, decide how many hierarchy levels this screen actually needs. Most screens need exactly three:1112| Level | Use for | Visual weight |13|---|---|---|14| Primary | The one action you want taken | Filled, brand color, highest contrast |15| Secondary | Alternative or supporting actions | Outlined or tonal (soft-filled), medium contrast |16| Tertiary / ghost | Low-stakes or frequent actions (toolbar icons, "cancel") | Text-only or icon-only, no border, lowest contrast |1718**One primary button per view, maximum.** If a screen wants two visually loud actions, that's a sign the screen is doing two jobs — split it, or demote one to secondary. This is the single fastest fix for the "uniform button weight" AI tell.1920**Destructive actions get their own color**, not just a secondary style — a red/danger accent, always with a confirmation step for anything irreversible (see `desktop-ui-surfaces-overlays` for confirmation dialogs).2122## Sizing2324Use exactly 3 sizes, tied to the spacing scale from foundations:2526- `sm` — 28–32px tall, dense toolbars, inline table actions27- `md` — 36–40px tall, the default for almost everything28- `lg` — 44–48px tall, rare — a single hero action, not a whole screen of them2930Horizontal padding should be roughly `1.5×` the control's font size on each side. Icon-only buttons should be square (equal width/height) at the same three sizes, with the icon centered and sized to about 60% of the control's height.3132**Minimum hit target: 24×24px even if the visible control is smaller.** Toolbar icon buttons especially — pad the clickable area beyond the drawn icon rather than shrinking the target to match tight visual spacing.3334## States — every control needs all of these, not just default and hover3536| State | What changes | Notes |37|---|---|---|38| Default (rest) | Base style | — |39| Hover | Subtle background/border shift, cursor becomes pointer | Gate behind `(hover: hover) and (pointer: fine)` so touch/trackpad taps don't get stuck in a fake hover state |40| Active / pressed | `transform: scale(0.97)` plus a slightly darker/tonal shift, ~120–160ms ease-out | This is the single highest-leverage "feels responsive" cue in the whole app — see `desktop-ui-motion` |41| Focus (keyboard) | Visible focus ring, distinct from hover | Never remove focus rings without replacing them — desktop power users navigate by keyboard constantly |42| Disabled | Reduced opacity (~40–50%) AND `cursor: not-allowed`; never rely on opacity alone since it reads as "loading" as easily as "disabled" | Disabled controls should still be legible enough to read their label |43| Loading | Spinner or progress replaces the label/icon, control stays the same size (no layout shift), stays disabled | Never let a button resize itself between idle and loading — reserve the space |4445## Toggles, checkboxes, radio buttons, segmented controls4647- **Checkbox**: multi-select within a set, independent options.48- **Radio**: single-select within a visible set of 2–6 options where all options should be visible at once.49- **Switch/toggle**: a single on/off setting that takes effect immediately (not "submit to apply"). Never use a toggle for something that requires a save action — that's a checkbox or a select.50- **Segmented control**: single-select where seeing all options side-by-side, and switching between them instantly, matters more than saving space (e.g. view mode: List / Grid / Timeline).5152All four need the same state coverage as buttons above, plus a clear, animated (not instant-snap) transition for the checked/unchecked state — a switch thumb should slide, not teleport (120–180ms, see `desktop-ui-motion`).5354## Icon buttons specifically5556- Never ship an icon-only button without a tooltip (see `desktop-ui-text-inputs`/`desktop-ui-feedback-messaging` for tooltip timing) unless the icon is truly universal (close ×, and even that benefits from one).57- Keep icon buttons visually consistent with the icon rules in `desktop-ui-icons` — same stroke weight, same optical size, across the whole toolbar.58- A row of icon buttons should have even spacing at the token scale (usually 4 or 8px gaps), not eyeballed.5960## Review format6162When reviewing existing button/control code, use this table, one row per issue:6364| Before | After | Why |65|---|---|---|66| `transition: all 0.3s` on a button | `transition: transform 150ms ease-out, background-color 150ms ease-out` | Animating `all` is imprecise and can catch properties you didn't intend to transition |67| No `:active` state | `transform: scale(0.97)` on press | Missing press feedback is the fastest way for a control to feel dead |68| Disabled button at full color, just `pointer-events: none` | Reduced opacity + `cursor: not-allowed` + still `pointer-events: none` | Disabled must be visually obvious, not just functionally inert |69| Three buttons, all filled brand color, same size | One filled (primary), two outlined/ghost (secondary/tertiary) | Establishes which action the user should actually take |