All articles
Design & UXJune 6, 2026 6 min read

Disabled Buttons Are a UX Bug: What to Ship Instead

Greyed-out buttons feel safe to designers and ship-ready to engineers. They're neither. Here's why disabled states quietly tank conversion, and the patterns we use to replace them without breaking validation.

Disabled Buttons Are a UX Bug: What to Ship Instead

Every product team has shipped this bug and called it a feature: a primary CTA that sits greyed out until the form is "valid." It looks tidy in Figma. It tests fine on the designer's machine. Then real users hit it, can't figure out what's missing, and bounce. Disabled buttons are one of the most expensive UX defaults still alive in 2026.

Why disabled buttons keep shipping

The pattern survives because it solves a problem for the team, not the user. Engineers like it because it short-circuits validation: if the button can't be clicked, you don't have to think about error states. Designers like it because the screen looks calm — no red text, no scary borders. PMs like it because it feels "guided."

The user gets none of that. They get a button that doesn't respond, no explanation of why, and often no visible indication of which field is the problem. On mobile, where the keyboard is covering half the form, this is brutal.

What the research keeps showing

We've run usability sessions on checkout and signup flows for clients in fintech, e-commerce, and SaaS. The pattern is consistent: when a primary action is disabled, a meaningful share of users tap it anyway, get no feedback, and either re-tap (assuming a bug) or abandon. The ones who stay often can't find the offending field, especially on long forms with conditional logic.

NN/g, GOV.UK, and Shopify Polaris have all published guidance against disabling submit buttons for this reason. Polaris is blunt: don't disable a button to prevent submission — let the user submit and tell them what's wrong.

The accessibility tax nobody budgets for

Disabled buttons have specific WCAG implications that most teams handwave.

  • Contrast is not required on disabled controls (WCAG 1.4.3 exempts them), which is why designers reach for 30% opacity. But users with low vision often can't tell a disabled button from a loading one, or from a button they've already tapped.
  • Screen readers announce "dimmed" or "unavailable" with no reason. NVDA and VoiceOver users get told the action is off, but not what to do next.
  • disabled removes the element from the tab order in most browsers, so keyboard users can't focus it to inspect a tooltip — which kills the popular "hover to see why it's disabled" pattern entirely.

If you must communicate "not ready yet," aria-disabled="true" is almost always the better primitive. It keeps the element focusable, announceable, and clickable — so you can show the user why on click.

The pattern we ship instead

The rule we use across client work: the primary action is always clickable. Clicking it either submits or explains.

That sounds obvious until you implement it. Here's the shape of it in React with Tailwind:

function SubmitButton({ form, onSubmit }: Props) {
  const [attempted, setAttempted] = useState(false);
  const errors = validate(form);
  const hasErrors = Object.keys(errors).length > 0;

  const handleClick = () => {
    if (hasErrors) {
      setAttempted(true);
      // Move focus to the first invalid field
      const firstError = Object.keys(errors)[0];
      document.getElementById(firstError)?.focus();
      return;
    }
    onSubmit(form);
  };

  return (
    <button
      type="button"
      onClick={handleClick}
      aria-disabled={hasErrors}
      aria-describedby={attempted && hasErrors ? 'form-errors' : undefined}
      className="bg-indigo-600 hover:bg-indigo-700 text-white font-medium
                 px-5 py-3 rounded-lg focus-visible:outline
                 focus-visible:outline-2 focus-visible:outline-offset-2
                 focus-visible:outline-indigo-500"
    >
      Continue
    </button>
  );
}

A few things to notice:

  • The button has full contrast at all times. No 40% opacity ghost state.
  • aria-disabled communicates the semantic state without removing the button from the keyboard flow.
  • On click with errors, we move focus to the first invalid field. That single line of code does more for conversion than any visual polish.
  • The error summary (form-errors) is announced via aria-describedby only after a submission attempt — so we're not yelling at the user before they've tried.

When you actually do need a non-actionable state

There are legitimate cases for a button that can't be pressed: an irreversible action mid-flight, a destructive action gated by a confirmation, a feature behind a paywall. In those cases the rule is: show the reason in the same place as the button.

For in-flight actions, swap the label to a loading state with an aria-live region. For paywall gates, replace the button with a link to the upgrade path — don't grey out the action and call it a day. For irreversible operations, use a confirmation dialog, not a permanently inert control.

Validation timing: the other half of the fix

Killing the disabled state without fixing validation timing just trades one bad UX for another. If every click on a half-filled form throws three red error messages, you've made things worse.

The pattern that's held up across our projects:

  1. Validate on blur, not on every keystroke. Per-keystroke validation punishes users mid-thought.
  2. Clear errors on input. As soon as the user starts editing an invalid field, the error message goes away. Don't make them re-blur to confirm they fixed it.
  3. Only validate the whole form on submit attempt. Before that, fields validate themselves independently.
  4. Summarise on submit. If the user clicks Continue and there are three errors, show an error summary at the top with anchor links to each field. GOV.UK's pattern here is the gold standard and worth copying.
<div
  id="form-errors"
  role="alert"
  aria-live="assertive"
  className="border-l-4 border-red-500 bg-red-50 p-4 mb-6"
>
  <h2 className="font-semibold text-red-900">
    There are {errorCount} issues to fix
  </h2>
  <ul className="mt-2 space-y-1">
    {errors.map(e => (
      <li key={e.field}>
        <a href={`#${e.field}`} className="text-red-700 underline">
          {e.message}
        </a>
      </li>
    ))}
  </ul>
</div>

Design token implications

If your design system has a button.disabled token, you probably have this pattern baked in across hundreds of components. Migrating away takes a deliberate token strategy.

What we recommend:

  • Keep a button.pending token for genuinely in-flight states (loading spinners, mid-transaction). Distinct visual treatment — usually a spinner, not reduced opacity.
  • Rename button.disabled to button.gated if you really need it, and reserve it for paywall or permission cases where the click should still do something useful (open an upgrade modal).
  • Remove disabled from your default form button variants entirely. Make engineers opt in.

This is a design system change, not a component change. Doing it piecemeal across product teams produces inconsistent behaviour, which is worse than the original bug.

What about destructive actions?

The one place we'll concede a brief disabled state is in confirmation dialogs that require typing a value — "type DELETE to confirm." Even there, we prefer a button that's clickable and explains itself on click, but the typed-confirmation pattern is established enough that users expect the gate.

If you go that route: make sure the disabled styling still meets 3:1 contrast against the background, use aria-disabled over disabled, and put the instruction ("Type DELETE above to enable") next to the button, not in a tooltip.

Where we'd start

If this is the first time you're auditing your product for this, don't try to rip out every disabled button on Monday. Start here:

  1. Find your top three conversion-critical CTAs — checkout, signup, primary upgrade. Fix those first.
  2. Instrument click attempts on each. You'll usually find a measurable rate of users tapping buttons that don't respond. That's your baseline.
  3. Ship the always-clickable pattern with focus-moves-to-first-error behaviour. Measure the delta over two weeks.
  4. Then tackle the design system tokens, once you've got evidence the team can point to.

If you'd rather have us run the audit with you, that's the kind of work our product design team does on engagements — usually as part of a broader conversion UX review. The disabled button is rarely the only thing we find, but it's almost always on the list.

#UX#Accessibility#Forms#Design Systems#React

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project