Buttons carry most interactions. They start purchases, submit forms, open panels, and cancel destructive actions. By the end of this article you will ship a small, themeable button system with clear states, keyboard-friendly focus, icon support, loading feedback, and motion that feels intentional. The CSS is framework-agnostic and production-ready.
Why Designing Better Buttons Matters
Good buttons lower cognitive load and speed up decisions. Visual hierarchy communicates which action is primary. Size and spacing guide attention on dense screens. Strong focus visibility improves keyboard flow. Clear affordances on hover and press make the UI feel responsive. A well-structured button system also reduces one-off styles and keeps teams from reinventing patterns on every view.
Prerequisites
You do not need a framework, only a text editor and a browser. The patterns below use modern CSS with fallbacks where helpful.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The HTML includes a small gallery of buttons that cover common use cases: primary, secondary, danger, icon-only, a dropdown with a caret, and a loading state. Each button uses semantic elements. Icon-only buttons include an accessible label.
<!-- HTML -->
<div class="demo">
<h3>Primary actions</h3>
<button class="btn btn--primary btn--caret-right">Continue</button>
<button class="btn btn--primary btn--lg">Buy now</button>
<h3>Secondary actions</h3>
<button class="btn btn--secondary">Learn more</button>
<button class="btn btn--secondary btn--caret-down">More</button>
<h3>Danger action</h3>
<button class="btn btn--danger">Delete</button>
<h3>Icon-only</h3>
<button class="btn btn--icon" aria-label="Open menu"><span aria-hidden="true" class="btn__glyph"></span></button>
<h3>Disabled and loading</h3>
<button class="btn btn--primary" disabled>Submit</button>
<button class="btn btn--primary btn--loading" aria-busy="true">Saving…</button>
<h3>Ghost / subtle</h3>
<button class="btn btn--ghost">Dismiss</button>
</div>
This markup keeps content and behavior predictable. Buttons use real <button> elements for actions. Text lives in the button content so screen readers announce it naturally. The icon-only button uses aria-label to provide a spoken label. Carets will be drawn with CSS on modifier classes, which keeps HTML uncluttered.
Step 2: The Basic CSS & Styling
Start with a small design token layer. These custom properties centralize color, spacing, radius, and motion timings. We also add a lightweight layout wrapper for the demo.
/* CSS */
:root {
--brand-50: #eef6ff;
--brand-400: #3b82f6;
--brand-500: #2563eb;
--brand-600: #1d4ed8;
--surface: #ffffff;
--surface-2: #f5f7fb;
--text: #0f172a;
--muted: #64748b;
--danger-500: #ef4444;
--danger-600: #dc2626;
--radius: 9999px; /* pill */
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--shadow-1: 0 1px 2px rgba(0,0,0,.06);
--shadow-2: 0 4px 12px rgba(0,0,0,.10);
--focus: 2px solid #1d4ed8;
--transition-fast: 120ms;
--transition-slow: 280ms;
--btn-h: 2.75rem;
--btn-gap: 0.5rem;
--btn-px: 1rem;
--btn-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #0b1220;
--surface-2: #0f172a;
--text: #e2e8f0;
--muted: #93a4b8;
--brand-50: #0b1b3a;
--brand-400: #60a5fa;
--brand-500: #3b82f6;
--brand-600: #2563eb;
--danger-500: #f87171;
--danger-600: #ef4444;
--shadow-1: 0 1px 2px rgba(0,0,0,.5);
--shadow-2: 0 6px 16px rgba(0,0,0,.45);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--btn-font);
color: var(--text);
background: var(--surface-2);
line-height: 1.4;
}
.demo {
max-width: 56rem;
margin: 2rem auto;
padding: 2rem;
background: var(--surface);
border-radius: 1rem;
box-shadow: var(--shadow-1);
}
.demo h3 {
margin: 1.5rem 0 .75rem;
}
.demo .btn + .btn { margin-left: .75rem; }
Advanced Tip: Keep all button colors routed through CSS variables like –btn-bg and –btn-fg. Variants then override the variables, not the full declaration list. This makes theming trivial and reduces cascade bloat.
Step 3: Building the Base Button
The base class sets layout, spacing, focus, and interaction states. Carets are drawn with triangles in pseudo-elements, which avoids external assets and keeps alignment consistent with the label.
/* CSS */
.btn {
--btn-bg: var(--brand-500);
--btn-bg-hover: var(--brand-600);
--btn-fg: #fff;
--btn-border: transparent;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--btn-gap);
min-height: var(--btn-h);
padding-inline: calc(var(--btn-px) + 2px);
padding-block: 0;
border-radius: var(--radius);
border: 1px solid var(--btn-border);
background: var(--btn-bg);
color: var(--btn-fg);
font: 600 0.95rem/1 var(--btn-font);
text-decoration: none;
cursor: pointer;
user-select: none;
box-shadow: var(--shadow-1);
transition: background-color var(--transition-fast) ease, transform var(--transition-fast) ease, box-shadow var(--transition-slow) ease;
}
.btn:hover { background: var(--btn-bg-hover); box-shadow: var(--shadow-2); }
.btn:active { transform: translateY(1px) scale(.99); }
.btn:focus-visible {
outline: var(--focus);
outline-offset: 2px;
}
/* disabled */
.btn[disabled],
.btn[aria-disabled="true"] {
cursor: not-allowed;
opacity: .6;
box-shadow: none;
transform: none;
}
/* Caret triangles */
.btn.btn--caret-right::after,
.btn.btn--caret-down::after {
content: "";
width: 0;
height: 0;
border: 5px solid transparent;
display: inline-block;
}
.btn.btn--caret-right::after {
border-left-color: currentColor; /* ► */
margin-left: .25rem;
}
.btn.btn--caret-down::after {
border-top-color: currentColor; /* ▼ */
margin-left: .25rem;
}
/* Primary variant uses base variables */
.btn.btn--primary {}
/* Icon-only button */
.btn.btn--icon {
--btn-bg: var(--brand-500);
--btn-bg-hover: var(--brand-600);
width: var(--btn-h);
padding: 0;
aspect-ratio: 1;
border-radius: 50%;
}
.btn__glyph {
position: relative;
width: 1.25rem;
height: 1.25rem;
display: inline-block;
background:
linear-gradient(currentColor, currentColor) center 35% / 100% 2px no-repeat,
linear-gradient(currentColor, currentColor) center / 100% 2px no-repeat,
linear-gradient(currentColor, currentColor) center 65% / 100% 2px no-repeat; /* menu (≡) */
filter: drop-shadow(0 0 0 transparent);
}
How This Works (Code Breakdown)
The base .btn sets inline-flex layout so text and icons align naturally. The gap property spaces the label and any glyph without extra wrappers. A very large –radius creates a pill shape. If you want to understand how rounded ends work, review how a pill relates to a circle by comparing to this guide on circle with CSS.
Custom properties like –btn-bg and –btn-fg live on the button so variants only override variables. This reduces the number of declarations and makes themes easy to stack. The transition list is limited to background-color, transform, and box-shadow for smooth hover without layout shifts. The active state nudges the button by 1px and scales by .99 to convey a press without blurring text.
Focus uses :focus-visible to avoid double outlines when clicking with a mouse, while still showing a strong ring for keyboard users. The disabled selectors use both disabled and aria-disabled for flexibility. This keeps pointer events intact for buttons that need tooltips even when disabled. If your design calls for a dropdown, the caret is drawn with a triangle in a pseudo-element. The borders trick creates triangles by making three sides transparent and one side colored. For a refresher, see how to build a triangle right with CSS and a triangle down with CSS. The icon-only button uses a 1:1 aspect ratio and a 50% border-radius for a perfect circle, consistent with touch targets.
Step 4: Variants, Sizes, and a Subtle Ghost
Variants change only variables and borders. Sizes adjust height and spacing. We also add a low-emphasis ghost button for tertiary actions on dense screens.
/* CSS */
/* Secondary */
.btn.btn--secondary {
--btn-bg: #e9eef7;
--btn-bg-hover: #dde6f5;
--btn-fg: #0b1220;
--btn-border: #c8d3ea;
color: var(--btn-fg);
}
/* Danger */
.btn.btn--danger {
--btn-bg: var(--danger-500);
--btn-bg-hover: var(--danger-600);
--btn-fg: #fff;
}
/* Ghost (textual) */
.btn.btn--ghost {
--btn-bg: transparent;
--btn-bg-hover: rgba(15, 23, 42, .06);
--btn-fg: var(--text);
--btn-border: transparent;
box-shadow: none;
border: 1px solid var(--btn-border);
}
/* Sizes */
.btn.btn--lg { --btn-h: 3.25rem; --btn-px: 1.25rem; font-size: 1.05rem; }
.btn.btn--sm { --btn-h: 2.25rem; --btn-px: .75rem; font-size: .9rem; }
/* Loading state */
.btn.btn--loading {
position: relative;
pointer-events: none;
color: transparent; /* keep layout */
}
.btn.btn--loading::before {
content: "";
position: absolute;
inset-inline-start: .75rem;
top: 50%;
width: 1rem;
height: 1rem;
margin-top: -.5rem;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
animation: spin .8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* High-contrast safety */
@media (forced-colors: active) {
.btn { border: 1px solid ButtonText; }
.btn:focus-visible { outline: 2px solid Highlight; }
}
How This Works (Code Breakdown)
Variants keep the same structure and interactions, which means muscle memory carries over from one action to another. Secondary leans on a subtle background with a visible border so it holds shape on white and dark surfaces. Danger keeps white text for contrast and uses a saturated hover to avoid ambiguity.
Ghost removes elevation and uses a very light hover fill. This is useful for dismiss or “skip” actions that should not compete with the primary. Sizes change the height and padding via variables, not hardcoded measurements, so any future line-height or font-size tweaks cascade correctly.
The loading state hides the label by making the text transparent but keeps the button width intact. The spinner sits to the left where an icon would go, so the alignment remains consistent. The animation is a simple rotate on a bordered circle with the right side transparent to create a spinner arc.
Advanced Techniques: micro-interactions and polish
Micro-interactions give buttons a tactile feel. Keep them restrained, fast, and consistent. The snippet below adds a gentle lift on hover, a pressed dip, and a subtle “ink” hover for ghost buttons. It also respects reduced motion preferences.
/* CSS */
.btn {
will-change: transform;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: translateY(1px) scale(.99);
}
/* Ghost ink ripple-lite */
.btn.btn--ghost {
overflow: hidden;
}
.btn.btn--ghost::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(120px 120px at var(--x, 50%) var(--y, 50%), rgba(0,0,0,.06), transparent 70%);
opacity: 0;
transition: opacity var(--transition-fast) ease;
}
.btn.btn--ghost:hover::before { opacity: 1; }
/* Capture mouse position for better ink anchor */
document.addEventListener('pointermove', function(e){
if(!(e.target instanceof Element)) return;
if(!e.target.classList.contains('btn')) return;
e.target.style.setProperty('--x', e.offsetX + 'px');
e.target.style.setProperty('--y', e.offsetY + 'px');
});
/* Motion safety */
@media (prefers-reduced-motion: reduce) {
.btn, .btn:hover, .btn:active { transition: none; transform: none; }
.btn.btn--loading::before { animation: none; }
}
The button lift relies on transform, which is cheap for the compositor and does not trigger layout. For the ghost button, a radial gradient fades in on hover using CSS variables to anchor the center under the pointer. The small pointermove script sets –x and –y on the hovered button only, so the ink originates near the cursor without any layout work.
Accessibility & Performance
Better buttons respect assistive tech, keyboard flow, and user preferences. They also keep paint and layout costs low during interaction.
Accessibility
Use <button> for actions and <a> for navigation. Screen readers announce roles correctly and browsers provide keyboard behavior out of the box. Do not remove focus outlines. Use :focus-visible to avoid double indications on mouse clicks. Keep a minimum target size near 44×44px; the default height above meets that on most densities.
For icon-only buttons, include an aria-label that communicates the action, not the icon name. For toggle buttons, manage aria-pressed so state is spoken. When a button triggers async work, add aria-busy=”true” during the operation. The loading spinner above pairs with this attribute. When disabled, prefer the disabled attribute on actual buttons. For link-lookalike buttons that cannot be clicked, set aria-disabled and stop activation in code to avoid accidental navigation.
Contrast needs to meet at least 4.5:1 for normal text. Primary and danger variants use white text over saturated colors to reach this. Ghost and secondary variants keep sufficient contrast on both light and dark schemes. Include a @media (forced-colors: active) block so high contrast mode users get visible borders and focus rings.
Dropdown and caret icons are decorative, so they should be hidden from assistive tech if you ever use a separate element. When drawn with ::after they are already not part of the accessibility tree. If you roll your own caret with borders, the triangles follow the same method used in the shape guides for a triangle down with CSS, which maps cleanly to a dropdown affordance.
Performance
Animate transform and opacity for smoothness. Avoid animating box-shadow on large radii for long durations since it triggers repaints. Keep hover transitions under 200ms so interactions feel crisp. The spinner uses a simple rotate which the compositor can handle. CSS variables prevent redundant declarations across variants, so the browser’s style recalc work is smaller. Pseudo-element icons avoid extra HTTP requests for small icons and align perfectly with text through flex layout. If you need chevrons instead of solid carets, you can draw them without images using borders or rotations, similar to the shapes explained in triangle right with CSS.
Ship Buttons People Trust
You built a small, dependable button system with clear hierarchy, crisp focus, and motion that communicates state. You also covered dropdown carets, icon-only targets, a loading indicator, and themes powered by CSS variables. Use these patterns as your baseline and extend them to your design language. Now you have a repeatable way to ship buttons that stay consistent across pages and projects.