BACK

Dec 5, 2025

0 words
0 m

Buttons Are Harder Than They Look

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>.

<!-- Good -->
<button type="button">Save</button>

<!-- Bad -->
<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.

<!-- In a form, prevent accidental submit -->
<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; /* touch target */
  min-width: 44px;  /* touch target */
}

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">
    <!-- x icon -->
  </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>
  <!-- content -->
  <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>.

<!-- Navigation -->
<a href="/pricing" class="btn">View pricing</a>

<!-- Action -->
<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 { /* base from earlier */ }

.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.

<!-- Good -->
<button type="button">Delete account</button>

<!-- Bad -->
<button type="button">Yes</button>

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.

Taseen Tanvir

1:12:27 UTC

Create a free website with Framer, the website builder loved by startups, designers and agencies.