Button set
Primary, secondary, ghost and danger, in three sizes, with every state covered.
<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>* { box-sizing: border-box; margin: 0; }
body { font: 16px/1.6 system-ui, -apple-system, sans-serif; padding: 24px; background: #fff; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 18px; }
/* ============ base ============ */
.btn {
--bg: #3b4fe4;
--bg-hover: #2a3abf;
--fg: #ffffff;
--border: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-size: .94rem;
font-weight: 500;
line-height: 1;
text-decoration: none;
white-space: nowrap;
padding: 11px 20px;
min-height: 42px; /* a comfortable tap target */
border: 1px solid var(--border);
border-radius: 9px;
background: var(--bg);
color: var(--fg);
cursor: pointer;
transition: background-color .14s ease, border-color .14s ease;
}
.btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--bg-hover); }
/* focus-visible, not focus: a mouse click should not leave a ring behind */
.btn:focus-visible { outline: 2px solid #3b4fe4; outline-offset: 2px; }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled, .btn[aria-disabled="true"] {
opacity: .5;
cursor: not-allowed;
}
/* ============ variants ============ */
.btn.secondary {
--bg: #ffffff;
--bg-hover: #f2f5f9;
--fg: #16202e;
--border: #d9e0ea;
}
.btn.ghost {
--bg: transparent;
--bg-hover: #f2f5f9;
--fg: #16202e;
--border: transparent;
}
.btn.danger {
--bg: #c93b3b;
--bg-hover: #a82f2f;
--fg: #ffffff;
}
/* ============ sizes ============ */
.btn.sm { font-size: .85rem; padding: 8px 14px; min-height: 34px; border-radius: 7px; }
.btn.lg { font-size: 1.02rem; padding: 14px 28px; min-height: 50px; border-radius: 11px; }
/* ============ icons ============ */
.btn svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
.btn.icon { padding: 0; width: 42px; min-height: 42px; }
/* ============ loading ============ */
.btn[aria-busy="true"] { pointer-events: none; }
.btn[aria-busy="true"]::before {
content: "";
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn:active:not(:disabled) { transform: none; }
.btn[aria-busy="true"]::before { animation-duration: 2s; }
}<!-- primary -->
<button class="inline-flex min-h-[42px] items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5
text-sm font-medium text-white transition hover:bg-indigo-700 focus-visible:outline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600
active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50">
Primary
</button>
<!-- secondary -->
<button class="inline-flex min-h-[42px] items-center justify-center gap-2 rounded-lg border border-slate-200
bg-white px-5 text-sm font-medium text-slate-900 transition hover:bg-slate-50
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:opacity-50">
Secondary
</button>
<!-- danger -->
<button class="inline-flex min-h-[42px] items-center justify-center gap-2 rounded-lg bg-red-600 px-5
text-sm font-medium text-white transition hover:bg-red-700">
Delete
</button>
<!-- icon only -->
<button aria-label="Settings"
class="inline-flex h-[42px] w-[42px] items-center justify-center rounded-lg border border-slate-200
bg-white text-slate-600 transition hover:bg-slate-50">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
</svg>
</button>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
Loaders and skeletons
Six loading indicators in pure CSS, plus the skeleton pattern that usually beats all of them.
HTMLCSSModal dialog
A modal built on the native dialog element — focus trap and backdrop included, for free.
HTMLCSSJavaScriptAccordion and FAQ
Built on details and summary — open, close and keyboard support with no JavaScript.
HTMLCSSTooltips
CSS-only tooltips on four sides, plus the anchor-positioning version.
HTMLCSSCheck 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.