Buttons seem simple.
They are not.
A good button feels instant.
It says the right thing.
It works with a mouse, touch, and keyboard.
It speaks to screen readers.
It handles loading and errors without being annoying.
This is how to build that kind of button.
Plain language.
Real code.
No fluff.
Start with the right element
Use a real <button>
.
Not a <div>
.
Not a <span>
.
<button type="button">Save</button>
<div onclick="save()">Save</div>
Why:
Keyboard works by default.
Focus works by default.
Screen readers know it is a button.
If the action changes the page or submits a form, set type
.
<button type="button">Save Draft</button>
<button type="submit">Publish</button>
The core states
A real button has states. You need to design and code each one:
Rest
Hover
Focus
Active
Disabled
Loading
Error or Success (optional)
Pressed or Toggled (if it is a toggle)
Base CSS you can reuse
button {
font: inherit;
padding: 0.6rem 1rem;
border-radius: 0.5rem;
border: 1px solid transparent;
background: #111;
color: #fff;
cursor: pointer;
line-height: 1;
min-height: 44px;
min-width: 44px;
}
button:hover { background: #000; }
button:active { transform: translateY(1px); }
button:disabled,
button[aria-disabled="true"] {
opacity: 0.6;
cursor: not-allowed;
}
button:focus-visible {
outline: 3px solid #4c9ffe;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none; animation: none; }
}
Accessible names that say the truth
The text must say the action.
Not “Click here”.
Say “Save”, “Delete”, “Create account”.
For icon-only buttons, add a label:
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20">
</svg>
</button>
Keyboard first
A button must work with the keyboard.
Tab to focus.
Enter or Space to activate.
With a real <button>
, you get this for free.
If you ever fake it, you must re-create all of it.
Do not fake it.
Disabled vs aria-disabled
Use disabled
when the control should not be interactive at all.
<button type="submit" disabled>Publish</button>
Use aria-disabled="true"
when you need it to stay focusable, like in a toolbar where layout should not shift.
<button type="button" aria-disabled="true" tabindex="0">Bold</button>
Block clicks in JavaScript:
document.addEventListener('click', e => {
const btn = e.target.closest('button[aria-disabled="true"]');
if (btn) e.preventDefault();
});
Loading state that prevents double actions
People double click when apps feel slow.
Stop that.
<button id="saveBtn" type="button" aria-live="polite">
Save
</button>
<script>
const btn = document.getElementById('saveBtn');
async function save() {
if (btn.dataset.loading === 'true') return;
btn.dataset.loading = 'true';
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Saving...';
try {
await fakeNetwork(1000);
btn.textContent = 'Saved';
} catch {
btn.textContent = 'Try again';
btn.disabled = false;
btn.dataset.loading = 'false';
return;
}
setTimeout(() => {
btn.textContent = prev;
btn.disabled = false;
btn.dataset.loading = 'false';
}, 1000);
}
btn.addEventListener('click', save);
function fakeNetwork(ms) {
return new Promise((res) => setTimeout(res, ms));
}
</script>
Toggle buttons the right way
For bold, mute, like, and other on/off actions, use aria-pressed
.
<button type="button" aria-pressed="false" id="likeBtn">Like</button>
<script>
const like = document.getElementById('likeBtn');
like.addEventListener('click', () => {
const v = like.getAttribute('aria-pressed') === 'true';
like.setAttribute('aria-pressed', String(!v));
like.textContent = v ? 'Like' : 'Liked';
});
</script>
Buttons that open things
If the button opens a dialog, menu, or popover, connect them.
Use aria-haspopup="dialog"
or menu
.
Use aria-expanded
to reflect open or closed.
Tie aria-controls
to the popup id.
<button
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="settingsDialog"
id="settingsBtn">
Settings
</button>
<dialog id="settingsDialog" aria-labelledby="settingsTitle">
<h2 id="settingsTitle">Settings</h2>
<form method="dialog">
<button type="submit">Close</button>
</form>
</dialog>
<script>
const dlg = document.getElementById('settingsDialog');
const trigger = document.getElementById('settingsBtn');
trigger.addEventListener('click', () => {
dlg.showModal();
trigger.setAttribute('aria-expanded', 'true');
});
dlg.addEventListener('close', () => {
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
});
</script>
Link or button
A quick rule.
If it goes to a new URL, use <a>
.
If it triggers an action in the current page, use <button>
.
<a href="/pricing" class="btn">View pricing</a>
<button type="button" class="btn">Add to cart</button>
If you style a link like a button, keep it a real link for accessibility and SEO.
React version with loading and errors
Here is a simple, headless React button.
It manages loading and errors without side effects leaking out.
import { useState } from "react";
type Props = {
onClick: () => Promise<void> | void;
children: React.ReactNode;
className?: string;
};
export default function ActionButton({ onClick, children, className }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
if (loading) return;
setError(null);
setLoading(true);
try {
await onClick();
} catch (e) {
setError("Something went wrong");
} finally {
setLoading(false);
}
}
return (
<div>
<button
type="button"
onClick={handleClick}
disabled={loading}
aria-busy={loading ? "true" : "false"}
aria-live="polite"
className={className}
>
{loading ? "Working..." : children}
</button>
{error ? (
<p role="status" aria-live="polite" style={{ marginTop: 8 }}>
{error}
</p>
) : null}
</div>
);
}
Usage:
<ActionButton onClick={() => fetch("/api/save", { method: "POST" })}>
Save
</ActionButton>
Visual variants without chaos
Keep a small set.
Primary, secondary, danger, subtle.
Do not invent a new shade for every screen.
.btn { }
.btn-primary { background: #111; color: #fff; }
.btn-secondary { background: #f3f4f6; color: #111; border-color: #e5e7eb; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-ghost { background: transparent; color: #111; }
.btn-primary:hover { background: #000; }
.btn-secondary:hover { background: #eaecef; }
.btn-danger:hover { background: #b91c1c; }
.btn-ghost:hover { background: #f6f7f9; }
Copy that reduces mistakes
Button text should be verbs.
Short.
Direct.
Save
Publish
Remove
Try again
Add to cart
Avoid vague labels.
Avoid “OK”.
Avoid “Yes” or “No” without context.
Touch, hit targets, and spacing
Make buttons easy to hit.
44 by 44 px minimum target.
Spacing between buttons reduces mis-taps.
Do not pack destructive and safe actions too close.
.button-row > * + * { margin-left: 0.5rem; }
Error and success feedback
Do not leave users guessing.
Show what happened.
Use role="status"
for updates.
<button id="buy" type="button">Buy</button>
<p id="msg" role="status" aria-live="polite"></p>
<script>
const buy = document.getElementById('buy');
const msg = document.getElementById('msg');
buy.addEventListener('click', async () => {
buy.disabled = true;
msg.textContent = 'Processing...';
try {
await fakeNetwork(800);
msg.textContent = 'Payment complete';
} catch {
msg.textContent = 'Payment failed. Try again.';
} finally {
buy.disabled = false;
}
});
</script>
Quick checklist
Real <button>
for actions.
Real <a>
for navigation.
Clear text or aria-label
for icon only.
Focus style you can see.
Hover, active, disabled, loading, error, success.
Prevent double clicks during network calls.
Toggle buttons use aria-pressed
.
Dialogs and menus update aria-expanded
and return focus.
Touch targets at least 44 by 44 px.
Respect prefers reduced motion.
Buttons are small.
The work behind them is not.
Get them right and your product feels better everywhere.
Click by click.
Detail by detail.
That is the job.