All articles
Design & UXOctober 28, 2026 6 min read

Disabled Buttons Are a UX Smell: What to Do Instead

Disabling a submit button feels polite and safe. It's usually neither. Here's why the greyed-out button pattern breaks accessibility, hides your real errors, and what to ship instead.

You've seen it a hundred times: a signup form where the "Create account" button sits greyed out until every field is magically correct. It feels tidy. It is actually one of the most quietly hostile patterns on the modern web, and in 2026 we should have better reflexes.

This is a short field guide on why disabled buttons are a UX smell, when the pattern is actually fine, and what to ship instead.

The real cost of a greyed-out button

A disabled submit button is a design choice that silently offloads three problems onto the user:

  1. It hides why the form isn't ready. The button is dim. The user doesn't know which field is wrong, or whether they've missed anything at all.
  2. It breaks the feedback loop. Clicking is how humans ask "what's wrong?" If the click does nothing, they have to hunt.
  3. It punishes assistive tech users hardest. Screen reader users navigating by button or by form controls can land on a disabled button that gives no reason, no hint, and no path forward.

In our experience auditing checkout and signup flows, moving from "disabled until valid" to "always enabled, validates on submit" tends to lift form completion for first-time users — not because validation got smarter, but because the dead-end disappeared.

What the specs actually say

WCAG 2.2 doesn't ban disabled controls, but Success Criterion 3.3.1 (Error Identification) and 3.3.3 (Error Suggestion) both assume the user can trigger a submission and get told what's wrong. A permanently disabled button means you've opted out of that contract. You're also skating around 1.4.3 (Contrast Minimum), because disabled states are explicitly exempt — which is exactly why they look so bad.

That exemption is a hint, not a license. If your "normal" state only works because the disabled state is unreadable by comparison, your design system is leaning on an accessibility loophole.

When disabling is actually fine

Let's be fair. There are cases where a disabled button is the right call:

  • Destructive actions on empty selections. "Delete 0 items" shouldn't be clickable.
  • Irreversible operations mid-flight. A "Pay now" button during an in-flight Stripe confirmation should not accept a second click.
  • Feature-gated controls where the user genuinely cannot proceed from this context (wrong role, wrong plan) — and even then, you owe them a reason adjacent to the button.
  • Steppers and wizards where "Next" has no meaning on step 0.

The common thread: the disabled state is transient and explainable, not a passive-aggressive way to enforce form validity.

The pattern to replace "disabled until valid"

The better default for forms is boring:

  • Keep the submit button enabled.
  • Let the user click.
  • On submit, run validation.
  • Move focus to the first invalid field and announce the error.

That's it. This is sometimes called submit-driven validation, and it respects three things: the user's intent to try, the screen reader's need for an announcement, and your analytics' ability to tell you which field is killing conversion (because now failed submits are events, not silent non-clicks).

You can still do inline validation on blur for fields with clear rules (email format, password length). The difference is you stop using the submit button as a gatekeeper.

A minimal React example

function SignupForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [submitting, setSubmitting] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const nextErrors = validate(data);
    setErrors(nextErrors);

    if (Object.keys(nextErrors).length > 0) {
      const firstBadName = Object.keys(nextErrors)[0];
      const field = formRef.current?.elements.namedItem(
        firstBadName,
      ) as HTMLElement | null;
      field?.focus();
      return;
    }

    setSubmitting(true);
    try {
      await createAccount(data);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form ref={formRef} onSubmit={handleSubmit} noValidate>
      {/* fields with aria-invalid and aria-describedby wired to errors */}
      <button
        type="submit"
        aria-disabled={submitting}
        aria-busy={submitting}
      >
        {submitting ? 'Creating account…' : 'Create account'}
      </button>
    </form>
  );
}

Note the two things we're not doing: we're not setting the HTML disabled attribute based on form validity, and we're not blocking submit while fields are empty. We let the user try, then we tell them.

disabled vs aria-disabled: pick the right tool

This distinction is where most teams go wrong, so it's worth being blunt.

Use native disabled when

The control genuinely must not do anything — not now, not with keyboard, not with assistive tech. Classic example: a "Pay" button for the two seconds we're talking to the payment processor. The native attribute removes it from the tab order and prevents activation.

Use aria-disabled="true" when

The control should look unavailable, but you still want it:

  • focusable (so users can land on it and read the reason),
  • announceable by screen readers as "dimmed" or "unavailable",
  • clickable, so you can show a toast or inline hint explaining why it's unavailable.

This is the pattern we reach for most often on primary submit buttons during loading. The user can still focus it, hear it, and understand the state.

<button
  type="submit"
  aria-disabled={isPending}
  onClick={(e) => {
    if (isPending) {
      e.preventDefault();
      announce('Still processing your previous request');
    }
  }}
>
  Save changes
</button>

Style it with [aria-disabled="true"] in CSS, not with :disabled, so you keep control of contrast.

Contrast and the "it's supposed to look dead" trap

Designers love a 40% opacity disabled state. It reads as "off," and WCAG lets you off the hook on contrast. Fine. But if your dim state is the thing users stare at for 20 seconds trying to figure out why nothing's happening, you've designed the most important button in the flow to be the least legible.

Two rules we apply on design system work:

  • Disabled states should still clear 3:1 contrast against the background. It's not required, but it's humane.
  • Never rely on color alone to signal disabled. Add an icon, a tooltip, or a label change ("Saving…"). Color-blind users and anyone on a sunny train platform will thank you.

If you're building tokens, give your disabled state a real name and a real value — don't just lower opacity at the component level. Something like color.action.primary.disabledFg and color.action.primary.disabledBg, tested in both themes, auditable in Figma and in code.

War story: the checkout that nobody could finish

We inherited a checkout where the "Place order" button stayed disabled until the billing address matched a strict regex. On mobile Safari, autofill inserted a trailing space into the postcode. The regex failed. The button stayed grey. There was no error message, because the user had never submitted — the button had never let them.

Support tickets described it as "the button is broken." It wasn't. It was doing exactly what the design asked. The fix took an afternoon:

  1. Enable the button at all times.
  2. Trim whitespace server-side (and client-side on blur).
  3. On submit, highlight the offending field with an inline message.
  4. Move focus to that field and announce the error via a polite live region.

Completed checkouts on iOS Safari climbed noticeably the following week. We didn't change the regex. We just stopped using the button as a wall.

A short checklist for your next form

  • Is the submit button enabled by default? It should be.
  • On submit failure, do you move focus to the first invalid field?
  • Do invalid fields have aria-invalid="true" and an aria-describedby pointing to the error text?
  • During in-flight submits, do you use aria-disabled + aria-busy instead of native disabled?
  • Does your disabled token clear 3:1 contrast?
  • Can a keyboard-only user tell why a disabled control is disabled, without clicking anything?

If any of these are "no," you have a cheap win waiting.

Where we'd start

Open your design system's Button component. Find every place disabled is bound to form validity. Replace it with aria-disabled tied only to in-flight state, and wire your submit handler to do real validation with focus management. Then audit your disabled token for contrast. That one afternoon of work tends to clean up more accessibility bugs than a week of individual fixes — and if you want a second set of eyes, our team does exactly this kind of audit as part of our design and UX engagements.

#accessibility#forms#design-systems#ux-patterns

Want a team like ours?

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

Start a project