Disabled Reason

Keeps a blocked control focusable and explains exactly what blocks it, with a jump to each fix, so disabled buttons aren't dead ends.

StopsDisabled button with no reason given

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/disabled-reason.json

The files land in your project. They're yours to edit.

Usage

disabled-reason-usage.tsx
import { DisabledReason, type BlockedReason } from "@/components/ui/disabled-reason"

<DisabledReason reasons={published ? [] : reasons} side="bottom" align="end" defaultOpen heading="Can't publish yet">
  <button
    type="button"
    onClick={() => setPublished(true)}
    className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-xs transition-colors hover:bg-primary/90 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
  >
    {published ? "Published" : "Publish"}
  </button>
</DisabledReason>

What it catches

  • Disabled buttons that give no reason
  • Every blocker at once, with a link to fix each
  • Presses on a blocked control, acknowledged with a shake

Props

DisabledReason props
PropTypeDefault
reasons* (required)

What's blocking the control. An empty list means it's usable and the wrapper steps aside.

(BlockedReason | string)[]—
children* (required)

The button or control to wrap. Don't give it `disabled`; this sets `aria-disabled` instead.

React.ReactElement<TriggerProps>—
heading

First line of the explanation.

string"Not available yet"
readyMessage

Read to screen readers when the control becomes usable.

string"Ready. You can use it now."
side

Preferred side for the explanation; it flips when there's no room.

"top" | "bottom""top"
align

Which edge of the control it lines up with.

"start" | "end""start"
open

Controlled open state of the explanation.

boolean—
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called when the explanation opens or closes.

(open: boolean) => void—

* required. Also accepts the native props of <span> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Sets aria-disabled on the child instead of disabled, so it stays focusable
  • Adds aria-describedby to the reasons while blocked
  • Opens on focus as well as hover and tap
  • The shake and list animations respect prefers-reduced-motion

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

  • Only lists the reasons you pass; it doesn't validate the form
  • Don't also set `disabled` on the child, or it can't receive focus

What it stops

Disabled button with no reason given

Also known as

disabled button · aria-disabled · tooltip · submit button · form validation · continue button · requirements · checkout button

Dependencies

Pairs well with