Back to blog

A Reliable Button in 2026: Clicks, Loading, and Accessibility

Hello HaWkers, a small button can cause a big problem: someone clicks “Save,” nothing seems to happen, and they click again. The result may be a second request, a confusing message, or a form that appears stuck. The MDN reference for the button element documents the native focus, disabled, and form submission behavior we need to respect before adding JavaScript.

Do you know exactly what your button shows before, during, and after an operation? Let's turn those states into an observable contract: the interface says what is happening, prevents repeated actions while waiting, and offers a clear path after success or failure. The example works without a framework and can be adapted to any product.

The contract starts before the first click

A reliable button has an understandable action and a verifiable result. “Place order” is clearer than “OK” when the screen offers several actions. The label must still identify the same action during loading: if it changes to “Submitting…,” people should recognize that they are still in the flow they started. Color, animation, and placement help, but they do not replace a message available to assistive technologies.

Start by describing the states in product language: ready, in progress, completed, and failed. For each state, define the visible text, whether the action can be activated, and what feedback appears. Do not treat “loading” as a final state: people need to know whether their request was accepted or whether they can try again. This small behavior table matters as much as the component you are about to build.

The contract also defines who controls the operation. The browser handles the native interaction of a <button>; your code handles the request and state transitions. If a button lives inside a form, type="button" avoids an unexpected automatic submission. For a conventional submission, use type="submit" and handle the form's submit event. The right choice depends on the flow, but it must be explicit so you do not create two submission paths.

A link is appropriate when the action navigates to another page. A button is appropriate when the action performs something on the current page. Swapping one for the other just to get a visual style can change keyboard navigation and the announced semantics. The native element already provides keyboard behavior, focus, and an identity that would be costly to rebuild with a clickable div.

HTML structure: action, feedback, and stable names

The form below keeps the field label, button, and feedback region close together. The region with role="status" receives progress and success messages. MDN's documentation for status describes its polite announcement: it reports a change without immediately interrupting what a person is reading. Visible text remains essential for errors that need attention.

<form id="newsletter-form">
  <label for="email">Your email</label>
  <input id="email" name="email" type="email" required />

  <!-- An explicit type avoids behavior changes when the component is reused. -->
  <button id="send-button" type="submit">
    <span class="button-label">Subscribe</span>
  </button>

  <!-- This region receives progress updates and the result. -->
  <p id="form-status" role="status" aria-live="polite"></p>
</form>

The button's accessible name comes from its text. If you replace all of its content with a spinning icon without text or an equivalent alternative, the control may lose its identity exactly when someone needs it. Keep a short, meaningful label; the status element can explain progress in more detail. Email is only an example: in a purchase, password change, or comment form, the message should match the actual action.

The required attribute and email input type let the browser perform basic validation, but they do not remove the need for server validation. They also do not mean every possible error has already been explained. If an API rejects an address, give a useful reason and let the person correct the field without losing what they already entered.

Block interaction during the operation without hiding context

The native disabled attribute prevents a <button> from being activated while an operation is in progress. MDN also notes that disabled controls normally cannot receive focus. That consequence matters: if the button was focused when you disabled it, do not rely on that button as the only place to communicate progress. The role="status" message remains visible and available. The aria-busy attribute can indicate that a region is being updated; on its own, it neither displays an understandable message nor blocks clicks.

Create one function that applies the state. It prevents one code path from changing the label while forgetting to update whether the action can be triggered. Here, data-state lets CSS and tests observe the same state without inferring it from a background color or the presence of an icon.

const form = document.querySelector('#newsletter-form');
const button = document.querySelector('#send-button');
const label = button.querySelector('.button-label');
const status = document.querySelector('#form-status');

function setState(state, message) {
  // One function keeps behavior and presentation in sync.
  button.dataset.state = state;
  button.disabled = state === 'loading';
  form.setAttribute('aria-busy', String(state === 'loading'));
  label.textContent = state === 'loading' ? 'Submitting…' : 'Subscribe';
  status.textContent = message;
}

setState('idle', '');

The code puts aria-busy on the form because that is the region awaiting an update. It does not put aria-busy on the button as a substitute for disabled. These signals serve different purposes: one communicates an update in progress; the other changes whether an action is available. Consider contrast and visible focus, too. A disabled button should still be readable; reducing its opacity too far can hide the very action the person just started.

Async requests and repeated click prevention

A visual block is not a sufficient guarantee. Code can dispatch an event, and two calls can arrive close together before the interface visibly updates. Keep a control variable in the flow and check it before starting the request. The server must also handle operations that cannot safely be duplicated, especially payments and orders. Protection in the interface improves the experience, but it does not replace an idempotency key or a business rule on the backend.

The example below uses fetch to send JSON. Replace the route with your real API and follow its authentication contract. fetch does not automatically reject an HTTP error response; you must check response.ok. The finally block restores the interface even if the server does not respond as expected. Avoid showing success before receiving confirmation that the operation succeeded.

let pending = false;

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  if (pending || !form.reportValidity()) return;

  pending = true;
  setState('loading', 'Submitting your subscription…');

  try {
    const email = form.elements.email.value.trim();
    const response = await fetch('/api/newsletter', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });

    // An HTTP error response does not throw an exception by itself.
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    setState('success', 'Subscription confirmed. Thank you!');
    form.reset();
  } catch (error) {
    console.error('Could not submit the subscription:', error);
    setState('error', 'Could not submit. Check your connection and try again.');
  } finally {
    pending = false;
    button.disabled = false;
    form.setAttribute('aria-busy', 'false');
  }
});

This code is for a demonstration. In production, a timeout can make the interface give up while the server is still finishing the work. A retry could repeat the operation. For a simple subscription, the backend can reject duplicate email addresses. For a charge, create an idempotency key and store the result on the server. The main point is to keep the person's expectations aligned with what the system actually knows.

When an error occurs, avoid a message that blames the user without evidence. “Check your connection” offers one possible step, but an authorization error calls for different handling. Show specific messages when the API returns an error code that is reliable and safe to expose. Log technical details separately rather than dumping stack traces or private data into the interface.

Visual variants must follow the same action rules

A product often has primary, secondary, and danger buttons. The visual variant communicates priority and consequences, but every variant needs the same operational states. A destructive action requires a more specific label, such as “Delete file,” and perhaps an additional confirmation. The variant must not become a reason to lose visible focus, readable text, or error feedback.

Use classes or data attributes to define appearance and state separately. The same flow can then display loading consistently in each variant. The CSS below shows a compact pattern: keyboard focus is prominent, and there is a visual waiting cue. Animation supports the text and can be reduced when the person requests less motion in their system settings.

button[data-variant="primary"] { background: #174ea6; color: white; }
button[data-variant="danger"] { background: #a32020; color: white; }

/* Keep focus clear for people navigating with a keyboard. */
button:focus-visible { outline: 3px solid #ffbf47; outline-offset: 3px; }
button[data-state="loading"] { cursor: progress; }
button:disabled { opacity: 0.8; }

/* Respect the preference for reduced motion. */
@media (prefers-reduced-motion: reduce) {
  button { animation: none; transition: none; }
}

Do not treat cursor: progress as sufficient communication: that cursor does not help someone using touch or a screen reader. The “Submitting…” label and status region remain the main sources of context. Test the variants with translated labels, too. “Subscribe” may fit comfortably while an equivalent label in French may be longer and overflow a fixed-width button.

How to test the contract with people and tools

Test both the normal and the difficult paths. With a fast API, watch for the success message and confirm that the button becomes available again. With a slow API, confirm that a second click does not create another request and that progress is visible. With an unavailable API, check that the error appears, the form retains the email address, and another attempt is possible. These tests protect against failures that a screenshot could never reveal.

Also walk through the flow with only a keyboard: navigate to the field, submit with Enter, see where focus goes, and try again after an error. Then repeat with a screen reader. The announcement of role="status" may vary by browser and assistive technology. Visible text, native semantics, and an actual test together are stronger than assuming one attribute solves everything. If a state changes very quickly, check that its information can still be perceived.

An automated check can exercise the double-click scenario. This example uses Playwright in a project where it is already installed and intercepts the route to control the response. The important assertion is the number of calls, not the button color. Adjust the app origin and selectors for your project before running it.

// Playwright example: keep the response pending until the route is released.
let requests = 0;
await page.route('**/api/newsletter', async (route) => {
  requests += 1;
  await new Promise((resolve) => setTimeout(resolve, 300));
  await route.fulfill({ status: 200, body: '{}' });
});

await page.getByLabel('Your email').fill('person@example.com');
await page.getByRole('button', { name: 'Subscribe' }).dblclick();
await page.getByText('Subscription confirmed. Thank you!').waitFor();
if (requests !== 1) throw new Error(`Expected 1 submission; received ${requests}`);

This test does not replace API verification. If the operation charges money or creates a resource, send the same idempotency key in a controlled repeat and check that the server returns the same result without performing the effect twice. The interface and API are part of one contract, even though they are tested at different levels.

Useful observability for the people maintaining the product

When someone reports “I clicked and nothing happened,” a team needs to reconstruct the path. Record the state transition, action type, request identifier, and error category. Do not record the email address, form content, or other personal data simply because the event made them available. A log that explains “request started, server rejected it, interface showed an error” is more useful than a collection of clicks without context.

To measure quality, look at the error rate per action, the time between click and feedback, and how often people repeat an attempt. Separate what happened on the client from what the server confirmed. A click is not a purchase; a completed animation is not an accepted order. If an experiment changes the button variant, keep the same definition of success in both groups so appearance is not confused with the result.

There is a related post on the blog about automated frontend testing with Vitest and Playwright. It can help extend this focused test to complete flows. Here, the priority is a simple rule: every state has a message, an allowed action, and a way to confirm the result.

Looking ahead: small buttons, big promises

As interfaces gain more automation, the button remains the place where a human intention becomes a system action. An assistant may suggest a value for a field, and an API may answer quickly, but people still need to understand what will be sent, whether it is in progress, and what to do if it fails. The quality of that feedback is part of a product's trustworthiness.

When reviewing your next form, write the contract before writing the CSS: action name, initial state, progress, success, error, retry, and recovery. Then test it with a slow connection, a keyboard, and assistive technology. This work reduces surprises for users and gives the team an objective way to investigate failures. The best button is not the one with the most animation; it is the one that keeps its promise on every path.

Let's go! 🦅

📚 Want to Keep Up With What Is Coming?

This article covered reliable buttons, but the ecosystem changes every week and not every useful idea becomes a full post here.

On X, I share what I am testing, behind-the-scenes notes from my projects, and new developments before they become articles.

Follow Me There

👉 Follow @jeffbruchado on X

💡 Daily posts about development, careers, and the tools I actually use

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments