Part
3
  |  
The Component Playbook
  |  
Chapter
9

Buttons: The Atom of the Interface

States, sizing, and the one-primary rule
Reading Time
13
mins
BACK TO DESIGN FOR DEVELOPERS

Most developers style every button the same. Filled background, brand color, white text, one size — stamped onto every action on the screen because it was the first thing that looked "done" and they never went back. Save the form: blue filled button. Cancel: blue filled button. Delete account: blue filled button, right next to the other two.

The result is a screen where five actions shout at the same volume, and the user has no idea which one you actually want them to take. A button's job is not just to be clickable. Its job is to tell the user where to go next. When every button looks identical, the interface has stopped giving directions — it's just a wall of equally loud doors.

The trap: five buttons, one volume

Open any unfinished SaaS app and you'll find a header with four buttons crammed into the top-right: "Invite", "Export", "Settings", "Upgrade". All filled. All the same blue. Maybe one is a slightly different shade because it was added later by a different part of the codebase.

A new user lands on that screen. Their eye has nowhere to rest, because nothing is louder than anything else. This is the same failure you met in One Loudest Thing — but at the component level. A screen needs one loudest element. Buttons are usually how you build it, and if every button is maxed out, you've spent your entire visual budget on noise.

The fix is not "make the buttons prettier." The fix is to admit that buttons carry hierarchy, and to encode that hierarchy in their styling. Some actions are primary. Most are not. The styling has to say which is which before the user reads a single word.

Framework · One Primary Action · OPA

Each view gets exactly one primary button — the filled, full-color action you most want the user to take. Everything else is secondary, tertiary, or ghost. If a screen has two primary buttons, it has zero: the eye can't pick a winner, so it picks nothing.

OPA is a constraint you apply per view, not per app. A settings page, a pricing page, and an empty dashboard each get their own single primary. The discipline is in the demotion: deciding which of your buttons earns the loud treatment, and styling the rest down on purpose.

If a screen has two primary buttons, it has zero.

Button hierarchy: five levels, not one

A button system needs about five levels. Not three, not eight — five covers nearly every real screen you'll build. Here's the full vocabulary, from loudest to quietest, plus the one that lives outside the ladder.

Primary is your one filled, full-saturation button per view. It carries the action you most want taken: "Create project", "Start free trial", "Save changes". Solid background, high contrast text.

Secondary is the supporting action that's still important but isn't the action. Usually an outlined or tonal button — a thin border or a light tinted fill, with colored text. "Cancel" next to a "Save". "Add another" next to "Done".

Tertiary is for low-stakes actions you want available but quiet. No fill, no border — just colored text that reads as a button by context and weight. Think "Skip for now" or "Learn more".

Ghost (sometimes called subtle) is the quietest interactive thing that still looks like a button: transparent background, neutral text, a faint hover state. Icon buttons in a toolbar, dismiss "X" controls, table row actions.

Destructive is orthogonal to the ladder — it's a meaning, not a loudness. A destructive action ("Delete", "Remove member") gets red, but it can be styled at any level: a filled red primary inside a confirmation dialog, or a quiet red-text tertiary in a list. Red signals consequence; the level signals prominence.

Here is the whole system in plain CSS, built on a few variables so you decide each value once and reuse it everywhere — the same instinct behind Decide Once, Reuse Forever.

.btn {
  font: 600 14px/1 system-ui, sans-serif;
  padding: 10px 16px;
  border-radius: 8px;
  border: 1px solid transparent;
  cursor: pointer;
}
.btn-primary   { background: #4f46e5; color: #fff; }
.btn-secondary { background: #eef2ff; color: #4f46e5; }
.btn-tertiary  { background: transparent; color: #4f46e5; }
.btn-ghost     { background: transparent; color: #475569; }
.btn-danger    { background: #dc2626; color: #fff; }

The Tailwind version is the same idea expressed as utility clusters you can wrap in a component:

const styles = {
  primary:   "bg-indigo-600 text-white hover:bg-indigo-700",
  secondary: "bg-indigo-50 text-indigo-700 hover:bg-indigo-100",
  tertiary:  "bg-transparent text-indigo-600 hover:bg-indigo-50",
  ghost:     "bg-transparent text-slate-600 hover:bg-slate-100",
  danger:    "bg-red-600 text-white hover:bg-red-700",
};

Notice what this buys you. The moment you have named levels, you stop asking "what color should this button be" on every screen and start asking the better question: "what level is this action?" That second question is design. The first is just guessing.

Key takeaway

Buttons don't have colors; they have levels. Decide the level and the styling follows.

One primary action per view: choosing it, demoting the rest

The hard part of OPA isn't styling the primary. It's the demotion — looking at a screen with four filled buttons and deciding that three of them must become quieter, even though every one of them felt important when you built it.

Use a simple rule to pick the winner. The primary is the action that moves the user's main job forward. On a new-invoice form, that's "Create invoice", not "Save draft". On an onboarding step, it's "Continue", not "Skip". On a pricing page, it's the plan you most want chosen. Everything that isn't the next step gets demoted, even useful things.

Watch a checkout header go from undifferentiated to directed:

<!-- Before: three primaries, no hierarchy -->
<button class="btn btn-primary">Save card</button>
<button class="btn btn-primary">Cancel</button>
<button class="btn btn-primary">Remove card</button>

<!-- After: one primary, one secondary, one destructive-tertiary -->
<button class="btn btn-primary">Save card</button>
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-tertiary" style="color:#dc2626">Remove card</button>

Same three actions. Same code, more or less. But now the eye lands on "Save card" first, "Cancel" reads as the safe way out, and "Remove card" is present but clearly the road less traveled — and tinted red so nobody clicks it by reflex.

✕ Undifferentiated
  • Every button filled and brand-colored
  • Destructive actions as loud as safe ones
  • Eye bounces, picks nothing
  • Two or three competing primaries
✓ One Primary Action
  • Exactly one filled button per view
  • Destructive actions tinted red, often demoted
  • Eye lands on the intended next step
  • Everything else secondary, tertiary, or ghost
Forms with one obvious submit

Forms are the cleanest place to practice OPA. The submit button is your primary; "Cancel" or "Back" is secondary or tertiary. If you ever feel the urge to give a form two primary buttons, you probably have two forms wearing a trench coat — split them.

A common objection: "But this screen genuinely has two equally important actions." It almost never does. When it truly does — a fork in a flow, like "Approve" versus "Reject" — that's a deliberate, designed exception, and you make both visually distinct from everything else around them so the fork itself becomes the loudest thing. The rule still holds: don't let primaries breed by accident.

All the states: ship six, not two

Here's where most hand-rolled buttons fall apart. A button is not one appearance — it's a small state machine, and the design isn't finished until every state is. There are six that matter:

  1. Default — the resting state.
  2. Hover — pointer is over it; usually a slightly darker or lighter background.
  3. Focus — reached by keyboard; needs a visible focus ring. Non-negotiable.
  4. Active — the instant of the press; often a touch darker or scaled down a hair.
  5. Disabled — not currently available; muted, no pointer, no hover.
  6. Loading — the action is in flight; a spinner, and the button is locked so it can't be double-fired.

Developers routinely ship two of these — default and hover — and call it done. This is exactly the gap The Four States Rule warns about for data views (empty, loading, error, ideal), pointed at the button. Same lesson, smaller atom: the states you skip are the ones your users hit, and a button that has no loading state is a button users will click three times.

The two most-skipped states are the two that hurt most. Focus is the one keyboard and screen-reader users depend on — strip the ring and you've locked them out, and most "we removed the ugly outline" CSS does exactly that. Loading is the one that prevents the double-submit that creates two charges, two invoices, two of everything.

Here's all six in CSS. It's not long — that's the point. There's no excuse to ship two.

.btn-primary:hover            { background: #4338ca; }
.btn-primary:active           { background: #3730a3; transform: translateY(1px); }
.btn-primary:focus-visible    { outline: 2px solid #4f46e5; outline-offset: 2px; }
.btn-primary:disabled         { background: #c7d2fe; cursor: not-allowed; }
.btn-primary[aria-busy="true"]{ opacity: .8; pointer-events: none; }

Two details earn their keep. Use :focus-visible, not :focus, so the ring shows for keyboard users but doesn't flash on every mouse click — that's the rule that makes a visible focus ring feel polished instead of noisy. And drive loading off aria-busy so the same attribute that styles the button also tells assistive tech the action is running.

The loading state is worth a tiny bit of JavaScript, because the failure it prevents is expensive:

async function onSubmit(e: React.FormEvent) {
  e.preventDefault();
  setBusy(true);                 // locks the button, shows spinner
  try {
    await createInvoice();
  } finally {
    setBusy(false);              // always restores, even on error
  }
}
The disappearing focus ring

The single most common accessibility regression in hand-built apps is outline: none with nothing put back. It makes the button look cleaner in a screenshot and silently breaks keyboard navigation for every user who can't use a mouse. If you remove the default outline, you owe the screen a :focus-visible style in the same commit.

The states you skip are exactly the ones your users hit.

Sizing, padding, and hit targets

Loudness gets the attention, but size and spacing are what make a button feel like it was built by someone who knew what they were doing. Three things to get right: the tap target, the padding, and the label.

Hit targets. A button needs to be big enough to hit without aiming. The working floor is 44 by 44 pixels of tappable area on touch — that's roughly a fingertip. A button can look smaller than that if its text is short, as long as you pad the clickable region up to size. A 28-pixel-tall icon button in a toolbar is fine visually; give it padding so the actual target clears 44.

.btn-icon {
  width: 44px;            /* visual size can be smaller, target is not */
  height: 44px;
  display: inline-grid;
  place-items: center;
}

Padding on the grid. Don't type arbitrary padding values. Pull them from your spacing scale so buttons line up with everything else on the page — this is The 8-Point Grid applied to a single component. A clean three-tier set:

.btn-sm { padding: 6px 12px;  font-size: 13px; }  /* dense tables, toolbars */
.btn-md { padding: 10px 16px; font-size: 14px; }  /* the default */
.btn-lg { padding: 14px 24px; font-size: 16px; }  /* hero, primary CTA */

Every value there is a multiple of 2, and the meaningful jumps are multiples of 8. The vertical padding sets the height; the horizontal padding is comfortably larger than the vertical so the label has room to breathe. A button that's as tall as it is padded sideways looks cramped — give horizontal padding 1.5 to 2 times the vertical.

Label clarity. The words on the button are part of the component, not an afterthought. A button should name the outcome, not the mechanism — "Create invoice", not "Submit"; "Send invite", not "OK". That's Label the Outcome, and on a primary button it matters most, because the primary is the one users read to decide whether to commit. "Submit" tells them nothing about what happens next; "Send invite" tells them exactly.

Key takeaway

A button has three sizes, six states, and one job: name the next step and make it easy to hit.

Get these right and the difference is immediate. Consistent padding off the grid, real hit targets, and outcome-named labels turn a row of clickable rectangles into something that reads as a product. None of it is hard. It's just rarely finished — and "finished" is the entire difference between an app that looks built by an engineer and one that looks built by a team.

What to do Monday morning

Audit every button on your busiest screen

Take your most-used view and count the buttons. Write down how many are currently filled and brand-colored. If it's more than one, you have an OPA problem to fix on this exact screen.

Pick the one primary, demote the rest

Choose the single action that moves the user's main job forward. Keep it filled. Convert every other button to secondary, tertiary, or ghost. The screen should now have exactly one loud button.

Build the five-level button as one component

Create a single Button component with variant props: primary, secondary, tertiary, ghost, danger. Delete the one-off button styles scattered through your app and route everything through it.

Add the four states you're missing

Most apps ship default and hover. Add active, a :focus-visible ring, disabled, and loading. The focus ring is the one you cannot skip — test it by tabbing to a button with the keyboard and confirming you can see where you are.

Lock buttons during async actions

Wire every button that triggers a network request to a busy state that disables it and shows a spinner until the request resolves. This kills double-submits and the duplicate records they create.

Fix hit targets and relabel the primary

Confirm every tappable button clears 44 by 44 pixels of target, even small icon buttons. Then rewrite your primary button labels to name the outcome — "Create project", not "Submit".

A button's job is to tell the user where to go next; when every button is loud, none of them can.