Skip to main content

Aligning Five Form Components Around aria-describedby

A person filling out a form on a clipboard
Apr 7, 20262 min readReact, Design Systems, Forms, REST APIs

Five Components, Five APIs

The shared-ui library has five form components: Input, Textarea, Select, Checkbox, and Switch. Input and Textarea were the most mature ones, with error, helperText, and success props, aria-describedby linking the input to its error message, aria-required on required fields, and a required asterisk in the label.

Select had none of that, and neither did Checkbox or Switch, so a developer building a form with all five components got consistent error handling on two of them and nothing on the other three.

The Forcing Function

The fix wasn't "add helperText to Select because Input has it." The forcing function was aria-describedby. Without it, a screen reader user who tabs to a Select with a validation error hears "Country, combobox" and nothing about what went wrong; the error message is there visually but invisible to the API.

Once aria-describedby is the requirement, the rest of the API falls into place: you need an errorId to point at, which means you need a stable id on the component (via useId), which means the error and helper text elements need matching id attributes.

The Pattern

Every form component now follows the same structure:

const generatedId = useId();
const inputId = id ?? generatedId;
const errorId = error ? `${inputId}-error` : undefined;
const helperId = helperText && !error ? `${inputId}-helper` : undefined;
const describedBy = errorId || helperId;

The describedBy logic gives error messages priority over helper text. If both exist, the screen reader announces the error, because that's the actionable bit; the helper text is supplementary.

<select
  aria-invalid={error ? 'true' : undefined}
  aria-required={required || undefined}
  aria-describedby={describedBy}
>

Checkbox and Switch

Checkbox and Switch needed more than new props; their DOM structure had to change too.

Checkbox uses a hidden native <input type="checkbox"> with a visual <span> overlay, and the aria-describedby goes on the <input>, not the visual element, because the <input> is what the screen reader actually interacts with:

<input
  type='checkbox'
  id={checkboxId}
  aria-invalid={error ? 'true' : undefined}
  aria-describedby={errorId}
  className='peer sr-only'
/>

Switch uses role="switch" on a <button>, which means it needs aria-labelledby instead of an htmlFor/<label> pair. The label is a <span> with a matching id:

<button
  role='switch'
  aria-checked={checked}
  aria-invalid={error ? 'true' : undefined}
  aria-labelledby={labelId}
  aria-describedby={errorId}
/>;
{
  label && <span id={labelId}>{label}</span>;
}

The Consistent Surface

After alignment, all five components share:

  • error prop that renders a role="alert" message with a linked id
  • aria-invalid="true" when an error is present
  • aria-describedby pointing to the error or helper text
  • aria-required on required fields
  • Consistent disabled styling (opacity-50, cursor-not-allowed)
  • Error state border color (border-error)

Input, Textarea, and Select additionally share the helperText and success props. Checkbox and Switch leave those off, since they're binary controls that rarely need supplementary text.

The Takeaway

API consistency in a form library isn't really ergonomics; it's accessibility. When one component has aria-describedby and another doesn't, the inconsistency is invisible to sighted users and impassable for screen reader users. I aligned the API around the accessibility requirement first, and the developer experience came along for free.