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:
- 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.
- It breaks the feedback loop. Clicking is how humans ask "what's wrong?" If the click does nothing, they have to hunt.
- It punishes assistive tech users hardest. Screen reader users navigating by button or by form controls can land on a
disabledbutton 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:
- Enable the button at all times.
- Trim whitespace server-side (and client-side on blur).
- On submit, highlight the offending field with an inline message.
- 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 anaria-describedbypointing to the error text? - During in-flight submits, do you use
aria-disabled+aria-busyinstead of nativedisabled? - 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.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Loading Buttons Without Lying: Pending States That Actually Communicate
Spinners inside buttons became a reflex, not a decision. Here's how to design and build pending states that tell the truth about what's happening — and what to do when the network takes longer than you promised.

Empty States Are the Feature You Shipped Without Designing
Empty states are the first screen most users actually see. Here's how to design them so they teach, convert, and don't make your product feel broken on day one.

Skeleton Screens vs Spinners vs Nothing: What Actually Feels Faster in 2026
Skeletons aren't a free upgrade over spinners, and sometimes showing nothing is the right call. Here's how to pick a loading pattern that matches the actual perceived latency.
