Skip to main content

Aligning Five Form Components Around aria-describedby

Apr 7, 20262 min readReact, Accessibility, Design Systems, Forms

Five Components, Five APIs

Our shared-ui library has five form components: Input, Textarea, Select, Checkbox, and Switch. Input and Textarea were the most mature: they had 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. A developer building a form with all five components would get 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 visually present but programmatically invisible.

Once aria-describedby is the requirement, the rest of the API falls into place: you need an errorId to point to, which means you need a stable id on the component (via useId), which means you need the error and helper text elements to have 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: error messages take priority over helper text. If both exist, the screen reader announces the error because that's the actionable information. 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 just new props: their DOM structure had to change.

Checkbox uses a hidden native <input type="checkbox"> with a visual <span> overlay. The aria-describedby goes on the <input>, not the visual element, because that's 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 helperText and success props. Checkbox and Switch omit those because they're binary controls that rarely need supplementary text.

The Takeaway

API consistency in a form library isn't 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. We've aligned our API around the accessibility requirement first, and the developer experience follows.