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:
errorprop that renders arole="alert"message with a linkedidaria-invalid="true"when an error is presentaria-describedbypointing to the error or helper textaria-requiredon 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.