HTML Formatterhtmlformatteronline.com

Button set

Primary, secondary, ghost and danger, in three sizes, with every state covered.

cssbuttonstatesloadingdisabled
<div class="row">
  <button class="btn">Primary</button>
  <button class="btn secondary">Secondary</button>
  <button class="btn ghost">Ghost</button>
  <button class="btn danger">Delete</button>
</div>

<div class="row">
  <button class="btn sm">Small</button>
  <button class="btn">Default</button>
  <button class="btn lg">Large</button>
</div>

<div class="row">
  <button class="btn" disabled>Disabled</button>
  <button class="btn secondary" disabled>Disabled</button>
  <button class="btn" aria-busy="true">Saving</button>
</div>

<div class="row">
  <button class="btn with-icon">
    <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
    New project
  </button>
  <button class="btn icon" aria-label="Settings">
    <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></svg>
  </button>
  <a class="btn secondary" href="#">A link that looks like a button</a>
</div>

How it works

Custom properties do the variants. The base class defines four variables; each variant redefines two or three of them. Adding a fifth variant is three lines, not a copy of the whole rule. Every other property — padding, radius, focus ring, disabled behaviour — is written once.

focus-visible, not focus. :focus puts a ring on the button after a mouse click, which designers then remove entirely, which breaks keyboard navigation. :focus-visible shows the ring only when the browser judges it useful — keyboard and assistive technology, not mouse. Use it and the argument goes away.

min-height: 42px. The WCAG target size guidance is 44 by 44 CSS pixels, and the small variant is deliberately below that for dense toolbars where everything is mouse-driven. If a small button is the primary action on a touch screen, do not use the small variant.

The loading state is aria-busy, not a class. That attribute is announced by assistive technology, and it doubles as the CSS hook for the spinner — so the visual state and the announced state cannot drift apart. pointer-events: none stops a second click while the request is in flight.

Icons inherit colour. stroke: currentColor means every variant gets correctly coloured icons with no extra rules, and hover states just work.

A link styled as a button is still a link. The last item in the demo navigates, so it is an anchor. Buttons do things; links go places. Getting this wrong breaks middle-click, open-in-new-tab and the browser's own history.

Accessibility notes

The icon-only button has an aria-label. Without one it has no accessible name at all — a screen reader announces "button" and nothing else. The SVG inside is aria-hidden so it does not add noise.

disabled removes a button from the tab order entirely, so a keyboard user cannot reach it to find out why it is unavailable. If the reason matters, use aria-disabled="true" instead: it announces the state, keeps the button focusable, and you block the action in JavaScript.

Contrast: the disabled style uses opacity, which reduces contrast below the threshold. That is accepted practice for disabled controls, which are exempt — but do not use the same treatment for anything a user is expected to read.

Making it yours

Change the four variables in .btn and every button on the site follows. Put them on :root instead if you want them to be part of a wider design system.

The transition deliberately lists properties rather than using all. transition: all animates things you did not intend, including layout properties, and it is a common cause of janky hover states.

Related templates

Check your version

Once you have edited this, the HTML validator will catch any tag you left unclosed, and the formatter will tidy the indentation. Both run in your browser.