aria-busy, aria-pressed, and the Polymorphic as Prop — Button Done Right
aria-busy, aria-pressed, and the Polymorphic as Prop — Button Done Right
A button that looks complete but has
aria-busymissing when loading,aria-pressedmissing when selected, and renders as a div when it should be a link. Three invisible bugs, one component.
The Button component is the most written UI primitive. It's also the most broken from an accessibility standpoint — not because of missing features, but because of subtle omissions that don't show up visually.
kui-react's Button in modules/ui/Button.tsx addresses three of these omissions systematically: loading state semantics, toggle state semantics, and polymorphic element rendering with correct type inference.
aria-busy for Loading State
<button
aria-busy={loading || undefined}
disabled={loading || disabled}
>
{loading && <Spinner aria-hidden="true" />}
{children}
</button>
When loading is true, two things happen: the button gets disabled (prevents double-submit) and it gets aria-busy="true".
Why both? disabled prevents interaction, but screen readers don't announce it as a "busy" state — they say "dimmed" or "unavailable." aria-busy="true" specifically tells screen readers "this element is in the middle of an operation." The combined signal is: "you can't click this right now, and the reason is that something is happening."
aria-busy={loading || undefined} — the || undefined pattern removes the attribute entirely when false. aria-busy="false" is valid HTML, but some screen readers treat its presence differently than its absence. Explicit false can cause unnecessary announcements on focus.
The spinner icon gets aria-hidden="true" — it's a visual indicator, not a content element. Screen readers shouldn't announce "spinning icon" in addition to aria-busy.
aria-pressed for Toggle State
<button
aria-pressed={selected ? true : undefined}
className={cn(
'button-base',
selected && 'bg-primary/10 border-primary text-primary',
)}
>
{children}
</button>
Toggle buttons — bold, italic, filter chips, view switchers — have two states: pressed and not pressed. Visual styling communicates this to sighted users. aria-pressed communicates it to screen readers.
aria-pressed={selected ? true : undefined} — the ternary is intentional. aria-pressed has three meaningful values: true, false, and absent. Absent means "this is not a toggle button" — appropriate for regular buttons. false means "this is a toggle button, currently not pressed." true means "pressed."
For a regular action button, selected is never passed. The button renders without aria-pressed. For a toggle button, selected is either true or false — in both cases aria-pressed appears on the element. But the ternary sends undefined when false is passed, which removes the attribute. To correctly mark a toggle button in the "off" state, pass aria-pressed={false} explicitly rather than relying on selected={false}.
This is a trade-off: the selected prop is a simpler API than requiring aria-pressed directly, but it loses the three-value semantics. The Button accepts aria-pressed as a passthrough prop for callers that need the full behavior.
Polymorphic as Prop
interface ButtonOwnProps {
as?: React.ElementType;
loading?: boolean;
selected?: boolean;
// ... other props
}
type PolymorphicProps<C extends React.ElementType, Props> = Props &
Omit<React.ComponentPropsWithRef<C>, keyof Props> & {
as?: C;
};
type ButtonProps<C extends React.ElementType = 'button'> = PolymorphicProps<
C,
ButtonOwnProps
>;
export function Button<C extends React.ElementType = 'button'>({
as,
loading,
selected,
children,
...rest
}: ButtonProps<C>) {
const Tag = (as ?? 'button') as React.ElementType;
const isNativeButton = Tag === 'button';
return (
<Tag
type={isNativeButton ? (rest.type ?? 'button') : undefined}
aria-busy={loading || undefined}
aria-pressed={selected ? true : undefined}
disabled={isNativeButton ? (loading || rest.disabled) : undefined}
{...rest}
>
{children}
</Tag>
);
}
PolymorphicProps<C, ButtonOwnProps> is the key: the component's own props take precedence, and the element's native props fill in the rest. TypeScript infers the correct prop types based on the as value.
// Correct types inferred:
<Button as="a" href="/dashboard">Go to Dashboard</Button>
// href is valid (anchor prop), type is not (not a button)
<Button as="div" role="button" tabIndex={0}>Custom</Button>
// role and tabIndex available, href is not
<Button loading>Save</Button>
// Default: button element, type="button" added automatically
type={isNativeButton ? (rest.type ?? 'button') : undefined} — native <button> elements without an explicit type default to type="submit" inside forms, which causes unintentional form submissions. The guard adds type="button" by default only for native buttons. Anchors, divs, and other elements don't get a type attribute.
disabled is only applied to native buttons — disabled on a div or anchor is not a valid attribute and doesn't disable interaction.
Focus Ring
<Tag
className={cn(
'focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
// ... variant styles
)}
/>
focus-visible:ring-* — focus ring only appears when the user navigated by keyboard. Mouse and touch users don't see the ring; keyboard users see a clear 2px blue outline. This is the correct behavior: mouse users already know where they clicked, keyboard users need a visible indicator.
focus-visible is now broadly supported without polyfills. For projects that still need IE11 support (rare but real), the :focus-visible polyfill ships a JavaScript fallback.
disabled:opacity-50 disabled:cursor-not-allowed — visual feedback for disabled state using CSS attribute selectors, not conditional class strings. Works for both disabled (native button) and aria-disabled="true" (non-button elements) depending on how the Tailwind variant is configured.
Icon Props
{leftIcon && <span aria-hidden="true">{leftIcon}</span>}
{children}
{rightIcon && <span aria-hidden="true">{rightIcon}</span>}
Icons are wrapped in aria-hidden="true" spans. The icon itself carries no information not already present in the button's text label. Screen readers announce the label; the icon is decorative.
For icon-only buttons, an aria-label is required:
<Button aria-label="Close dialog">
<XIcon aria-hidden="true" />
</Button>
No children text → no accessible name from content → aria-label provides it explicitly.
Trade-off
The PolymorphicProps type is complex. TypeScript inference can fail on deeply nested generic types, and the error messages are cryptic. For most teams, a simpler approach — separate Button, ButtonLink, and ButtonDiv components — is more maintainable even if it's more verbose.
The selected → aria-pressed mapping loses the three-value semantics of aria-pressed. A toggle button in the "off" state should ideally have aria-pressed="false", not absent. The component works correctly for the common case but requires a direct aria-pressed prop for precise control.
Business Impact
Accessibility failures in interactive elements create the widest user impact — buttons are everywhere. A loading button without aria-busy means screen reader users don't know a request is in progress. A toggle button without aria-pressed means they can't tell if a filter is active. An anchor rendered as a button means keyboard navigation order breaks (links and buttons have different keyboard interaction models).
For enterprise applications — HR systems, healthcare portals, government services — accessibility compliance is a procurement requirement. Getting buttons right means passing the audit before it's required, not scrambling to fix it after.
Something to Try
Open your current Button component and check three things: does it have aria-busy when loading? Does it have aria-pressed for toggle variants? Does the as prop (or equivalent) correctly exclude native-button-only props like disabled and type when rendering as an anchor? The answers tell you where the gaps are.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox