Disabled Buttons Are a UX Bug: What to Do Instead
Disabled primary buttons feel safe but they hide the real problem: the user doesn't know what's wrong. Here's a pattern that keeps intent visible and errors honest.
Greyed-out submit buttons look tidy in a Figma frame and feel responsible in code review. In production they quietly break flows: users click, nothing happens, and they never learn why. Disabled primary actions are almost always a design smell — here's how we've stopped shipping them.
The problem with a disabled primary button
The reasoning behind disabling a submit button is usually well-intentioned: "don't let them submit invalid data." But that logic optimises for the developer's mental model, not the user's. From the user's side, a disabled button says three unhelpful things at once:
- Something is wrong.
- I won't tell you what.
- I won't tell you where.
On touch devices it's worse. There's no hover state to trigger a tooltip, so a disabled button is just a dead rectangle. On screen readers, disabled removes the element from the tab order entirely in most implementations, so a keyboard-only user hits Tab, sails past the action they came for, and has no idea it existed.
We've seen this pattern tank completion rates on multi-step forms — checkout, KYC onboarding, job applications. The fix is almost never "add a better tooltip." It's to stop disabling the button in the first place.
When disabling is genuinely fine
Before the pitchfork: there are legitimate uses.
- In-flight actions. A button showing a spinner mid-submit should be non-interactive to prevent double-submits. Use
aria-disabled="true"and swap the label to something like "Saving…". - Truly unavailable actions. "Delete workspace" when the user isn't an owner. The action isn't blocked by fixable input — it's blocked by identity or permission. Even here, an explanation nearby beats a mystery grey pill.
- Destructive confirmations. A short countdown before "Delete permanently" becomes clickable is a deliberate friction pattern, not a validation gate.
Everything else — required fields, format mismatches, password rules, terms not accepted — is a validation problem masquerading as a button state.
The pattern: clickable, honest, specific
The rule we use on client work:
Let the user click. If they can't proceed, tell them exactly why, exactly where, and move focus to the first thing they can fix.
That's it. The button is always live. Clicking it either submits or produces a targeted, accessible error state. No guessing, no dead ends.
Here's the minimum viable implementation in a React + Tailwind context. It's not clever — that's the point.
import { useRef, useState } from "react";
type FieldError = { name: string; message: string };
export function SignupForm() {
const [errors, setErrors] = useState<FieldError[]>([]);
const [submitting, setSubmitting] = useState(false);
const summaryRef = useRef<HTMLDivElement>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
const found: FieldError[] = [];
if (!data.get("email")) {
found.push({ name: "email", message: "Enter your work email." });
}
if ((data.get("password") as string)?.length < 12) {
found.push({
name: "password",
message: "Password needs at least 12 characters.",
});
}
if (found.length) {
setErrors(found);
// Move focus to the summary so SR users hear it
requestAnimationFrame(() => summaryRef.current?.focus());
return;
}
setSubmitting(true);
// ...submit
}
return (
<form onSubmit={handleSubmit} noValidate>
{errors.length > 0 && (
<div
ref={summaryRef}
tabIndex={-1}
role="alert"
aria-labelledby="err-title"
className="mb-6 rounded border border-red-300 bg-red-50 p-4"
>
<h2 id="err-title" className="font-semibold text-red-900">
Fix {errors.length} thing{errors.length > 1 ? "s" : ""} to continue
</h2>
<ul className="mt-2 list-disc pl-5 text-red-800">
{errors.map((err) => (
<li key={err.name}>
<a href={`#${err.name}`} className="underline">
{err.message}
</a>
</li>
))}
</ul>
</div>
)}
{/* fields here, each with aria-invalid and aria-describedby */}
<button
type="submit"
aria-disabled={submitting}
className="rounded bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-700 aria-disabled:cursor-progress aria-disabled:bg-indigo-400"
>
{submitting ? "Creating account…" : "Create account"}
</button>
</form>
);
}
A few details worth flagging in that snippet:
aria-disabledinstead ofdisabledwhen the button is in-flight. The element stays focusable, screen readers announce the state, and we retain full control over click handling.- The error summary is a focusable region with
role="alert". On failed submit we push focus there — this is the single biggest accessibility win most teams miss. - Each error links to its field anchor. Keyboard users can jump straight to the broken input.
Why aria-disabled over disabled
Native disabled does four things at once: removes the element from the tab order, blocks pointer events, blocks form submission, and greys the element via user-agent styles. aria-disabled="true" communicates state to assistive tech without any of the behavioural side effects. You choose which parts you want.
For an in-flight submit button, we usually want: focusable (yes), announced as busy (yes), visually muted (yes), clickable again if the network dies (yes). Native disabled gives us the opposite of most of that.
What errors should actually say
Once you commit to letting the user click, the error copy becomes the product. A few rules we hold contributors to:
- Name the field, name the fix. Not "Invalid input." Say "Phone number needs a country code, like +44."
- Show, don't scold. "Password must contain one uppercase" is fine. "Your password is too weak" is judgement without direction.
- Validate on blur, not on keystroke. Live validation while typing punishes users mid-thought. Blur is the natural checkpoint. The exception is positive reinforcement — a green tick as a password meets each rule is welcome.
- Never clear the field on error. This still happens. It shouldn't need saying.
The conversion story
We don't have a universal number to give you, and anyone quoting a precise conversion lift from this change alone is guessing. What we can say from our own client work: on forms with three or more fields and previously-disabled submit buttons, moving to a clickable-with-summary pattern consistently reduces support tickets about "the button doesn't work" and shortens time-to-first-successful-submit. On one B2B onboarding flow, the drop in "nothing happens when I click submit" tickets was large enough that the support team noticed before analytics did.
The mechanism is boring: users who used to bounce silently now see a specific error, fix it, and continue. You're not gaining new users — you're keeping the ones who were already trying.
Edge cases and pushback
"But then invalid submissions hit our server"
Good. Server-side validation is the only validation you can trust anyway. Client validation is a UX affordance, not a security boundary. If your API can't handle a bad payload gracefully, that's a separate bug.
"Designers keep asking for the greyed-out state"
Offer them a middle ground: the button can look less prominent when the form is untouched (lower contrast, no shadow) and gain visual weight as fields fill in. It stays clickable throughout. This gives the visual affordance of progress without the dead-end behaviour.
"What about multi-step wizards?"
Same rule. "Next" should always be clickable. If the current step is incomplete, clicking Next produces the same error summary treatment and keeps the user on the step. Do not lock them into a step with a disabled Next button and a form full of unmarked mistakes — that's the worst-case version of this anti-pattern.
A quick audit you can run today
Open your product. Find every primary button that has a disabled state. For each one, answer:
- Is it disabled because of fixable user input? → Convert to clickable-with-errors.
- Is it disabled because of in-flight state? → Use
aria-disabledand a loading label. - Is it disabled because of permissions or availability? → Keep it, but add adjacent text explaining why.
Most teams find that 70–80% of their disabled buttons fall into the first bucket. That's the pile worth fixing.
Where we'd start
Pick your highest-traffic form — signup, checkout, or the primary lead capture. Remove the disabled state from the submit button, add a focusable error summary with anchor links to each field, and push focus to the summary on failed submit. Ship it behind a flag, watch your support inbox for a week, and compare completion rates. If you want a second opinion on the pattern in your specific stack, our team does design and engineering audits that usually surface a handful of these in the first hour.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Figma Variables to Tailwind Tokens: A Workflow That Actually Survives Handoff
Design tokens die at the handoff boundary. Here's the Figma Variables → Tailwind pipeline we use so color, spacing, and radius changes make it to production without a designer filing a Jira ticket.

Empty States Are a Product Decision, Not a Design Afterthought
Most empty states are decorative apologies. The good ones sell the product, teach the mental model, and reduce support tickets. Here's how we design and build them without shipping cartoon mascots nobody asked for.

Skeleton Screens Are Lying to Your Users: When to Use Them and When to Stop
Skeleton screens became the default loading pattern, and most teams use them wrong. Here's when they help perceived performance, when they hurt it, and what to reach for instead.
