Skip to content

Form Input Accessibility: Dynamic aria-describedby for Multi-State Messages

3/17/2026Mobile DevelopmentKUIreact6 min read

Form Input Accessibility: Dynamic aria-describedby for Multi-State Messages

"Email address is invalid." When does a screen reader user hear this — on focus, on error, or on change? Any of these answers can be wrong.

Accessibility for form inputs is often understood as "add a label and you're done." But a single input can carry multiple contextual messages simultaneously: a hint ("at least 8 characters"), an error ("password too short"), or a success confirmation ("strong password"). Which one goes into aria-describedby? What takes priority? If all three exist at once, what does a screen reader announce?

kui-react's Input component in modules/ui/Input.tsx answers these questions systematically.

Dynamic aria-describedby Computation

const describedBy = [
  hint && !error && !success ? `${id}-hint` : null,
  error ? `${id}-error` : null,
  success && !error ? `${id}-success` : null,
].filter(Boolean).join(' ');

This array recomputes on every render. The logic:

  • hint exists, no error, no success → hint ID is active
  • error exists → hint is suppressed, error ID is active
  • success exists and no error → success ID is active

Error always overrides hint. When there's an error, the user needs to hear the error, not the helper text. Success and error are mutually exclusive — only one can be active.

<input
  {...props}
  aria-describedby={describedBy || undefined}
  aria-invalid={state === 'error'}
  aria-required={required || undefined}
/>

describedBy || undefined — an empty string would produce aria-describedby="", which is different from omitting the attribute. Some screen readers behave unexpectedly with empty ID lists. undefined removes the attribute entirely.

Message Elements

Each message type gets a different role:

{hint && !error && (
  <p id={`${id}-hint`} className="text-xs text-text-secondary">
    {hint}
  </p>
)}

{error && (
  <p
    id={`${id}-error`}
    className="text-xs text-error"
    role="alert"
  >
    {error}
  </p>
)}

{success && !error && (
  <p id={`${id}-success`} className="text-xs text-success">
    {success}
  </p>
)}

role="alert" only on the error paragraph. Alert role means aria-live="assertive" — when the error is added to the DOM, the screen reader interrupts whatever it's doing and announces it.

Hint and success have no role. They're read when the user focuses the input (via aria-describedby), not announced proactively. This is deliberate: hint sits quietly, the user reads it when they want to. Error is urgent — it should interrupt.

Required Asterisk

{required && (
  <span className="ml-0.5">
    <span aria-hidden="true">*</span>
    <span className="sr-only">(required)</span>
  </span>
)}

The visual asterisk is aria-hidden="true" — screen readers don't announce "*". The sr-only span says "(required)" — screen readers announce this, sighted users don't see it.

Alternative: rely solely on aria-required="true". But not all screen readers verbalize this consistently — some say "required," some say nothing. Explicit text is more reliable across screen readers and locales.

Number Stepper Keyboard Accessibility

{type === 'number' && (
  <div className="flex flex-col">
    <button
      type="button"
      tabIndex={-1}
      aria-hidden="true"
      onClick={() => synthesizeChange(Number(value) + (step ?? 1))}
    >
      ▲
    </button>
    <button
      type="button"
      tabIndex={-1}
      aria-hidden="true"
      onClick={() => synthesizeChange(Number(value) - (step ?? 1))}
    >
      ▼
    </button>
  </div>
)}

Stepper buttons are tabIndex={-1} and aria-hidden="true". Why?

Native <input type="number"> already supports arrow keys for increment/decrement. The stepper buttons are a visual affordance for mouse users. A screen reader user with focus on the input already uses arrow keys — seeing these buttons separately in the tab order would be confusing.

synthesizeChange(newValue):

function synthesizeChange(newValue: number) {
  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype,
    'value',
  )?.set;
  nativeInputValueSetter?.call(inputRef.current, String(newValue));
  inputRef.current?.dispatchEvent(new Event('input', { bubbles: true }));
}

For a React controlled input, directly setting .value doesn't trigger React's onChange. The native setter + dispatched synthetic event forces React's event system to pick up the change. This is the same pattern React's own test utilities use.

ID Management

const generatedId = useId();
const id = props.id ?? generatedId;

useId() produces SSR-safe unique IDs — no hydration mismatch because server and client render components in the same order. Users can pass their own id; the hint, error, and success elements use that ID as the base for their own IDs (${id}-hint, ${id}-error, ${id}-success).

Visual State Feedback

const stateStyles = {
  default: 'border-border-default focus:border-border-focus',
  error: 'border-error focus:border-error',
  success: 'border-success focus:border-success',
  disabled: 'border-border-default opacity-50 cursor-not-allowed',
};

Border color carries state information visually. But color alone violates WCAG 1.4.11 (Non-text Contrast) — color cannot be the only visual indicator. The error message text, icon, and border change together provide multiple cues.

{state === 'error' && <ErrorIcon aria-hidden="true" className="text-error" />}
{state === 'success' && <CheckIcon aria-hidden="true" className="text-success" />}

Icons are aria-hidden="true" — visual support only. The information they carry is already communicated through aria-describedby and the message text.

A Real Test

Test this component with NVDA or VoiceOver:

  1. Tab to the input — does the hint announce?
  2. Enter an invalid value — does the error announce immediately?
  3. Correct the value — does the error clear and success announce?
  4. Submit a required input empty — does "required" announce?

Most form components fail the second test. The label reads, the hint is silent.

Trade-off

role="alert" fires every time the error is added to the DOM. If validation runs on every keystroke, screen readers announce an error on every character typed — disruptive. Debounce validation or trigger it only on blur. kui-react leaves this decision to the caller — the state prop is externally controlled.

Multiple simultaneous errors (complex form validation) cause aria-describedby to list multiple IDs. Screen readers read them sequentially: "Email invalid. Password too short. Passwords don't match." This can be overwhelming. aria-atomic and aria-relevant can tune this behavior.

Business Impact

Form accessibility is where the most user abandonment happens. A blind user who can't complete checkout produces a zero conversion event. This is both an ethical concern and a measurable business impact.

In Europe, the EAA (European Accessibility Act) entered into force in 2025 — organizations providing services in the EU face legal accessibility requirements. Getting form inputs right is foundational.

Something to Try

Add three things to your current form input component:

  1. aria-invalid={!!error} — communicates error state to screen readers
  2. role="alert" on the error message — auto-announced when it appears in the DOM
  3. aria-describedby with unique IDs for hint and error, linked to the input

These are five-minute changes that directly improve usability for screen reader users.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox