Skip to main content
New to FormEngine? It’s a React library that renders forms from JSON schemas — free, MIT-licensed. Start here →
Accessibility is not optional for enterprise forms — WCAG 2.1 AA compliance is required in the EU (EAA), US federal (Section 508), and most large SaaS procurement checklists. This guide covers how FormEngine supports accessibility and what you need to do in your own code.

What FormEngine gives you out of the box

When you use FormEngine with a ready-made UI pack (React Suite, Material UI, Mantine), you inherit these accessibility behaviors for free:
  • Semantic HTML — inputs, labels, and form elements render as real <input>, <label>, <select>, <button> tags
  • Keyboard navigation — Tab, Shift+Tab, Enter, Escape behavior matches native HTML controls
  • Focus management — required fields focus on validation failure; modals trap focus
  • ARIA attributesaria-required, aria-invalid, aria-describedby are set when appropriate
  • Label association — the label prop wires htmlFor correctly to each input’s id
The required validation rule automatically adds aria-required="true" and a required CSS class. See Required property.

What you need to add

1. Provide meaningful labels

Every field needs a visible label or an aria-label. Placeholder text is not a substitute for a label.
{
  "key": "email",
  "type": "RsInput",
  "props": {
    "label": { "value": "Email address" },
    "placeholder": { "value": "you@example.com" }
  }
}
For fields where a visible label doesn’t fit the design, use aria-label:
{
  "key": "search",
  "type": "RsInput",
  "props": {
    "aria-label": { "value": "Search products" }
  }
}

2. Describe complex fields

For fields with format requirements or constraints, add a description that screen readers read alongside the label:
{
  "key": "password",
  "type": "RsInput",
  "props": {
    "label": { "value": "Password" },
    "aria-describedby": { "value": "password-hint" }
  }
}
Then render <p id="password-hint">Minimum 8 characters, one number.</p> near the field. Or use FormEngine’s Tooltip component, which wires aria-describedby automatically.

3. Announce validation errors to screen readers

Put form-level error messages inside a live region so screen readers announce them on change:
<div role="alert" aria-live="polite">
  {formErrors.length > 0 && (
    <ul>
      {formErrors.map((err) => <li key={err.field}>{err.message}</li>)}
    </ul>
  )}
</div>
Field-level error messages rendered by UI packs already include role="alert" — check your pack’s implementation or wrap errors in a live region yourself.

4. Manage focus on validation failure

When a user submits and validation fails, move focus to the first invalid field:
const handleSubmit = async () => {
  const validation = await viewerRef.current?.formData.getValidationResult()
  if (validation && Object.keys(validation).length > 0) {
    const firstInvalidKey = Object.keys(validation)[0]
    document.querySelector<HTMLElement>(`[name="${firstInvalidKey}"]`)?.focus()
    return
  }
  await submit()
}

5. Give buttons clear action labels

Avoid generic labels like “Submit” or “OK” for forms with multiple actions. Use specific action verbs:
{
  "key": "save",
  "type": "RsButton",
  "props": { "children": { "value": "Save draft" } }
}

6. Don’t rely on color alone

Validation state must be communicated by more than color. FormEngine UI packs add icons and aria-invalid alongside red borders — if you build custom components, do the same:
<input
  aria-invalid={hasError}
  aria-describedby={hasError ? errorId : undefined}
  style={{ borderColor: hasError ? 'red' : 'gray' }}
/>
{hasError && (
  <span id={errorId}>
    <ErrorIcon /> {errorMessage}
  </span>
)}

Keyboard support

FormEngine forms inherit keyboard behavior from the underlying UI library. Minimum expectations:
  • Tab / Shift+Tab — move between fields in source order
  • Space / Enter — activate buttons and checkboxes
  • Enter inside a text input — submit the form (if a submit button exists)
  • Escape — close open dropdowns, date pickers, modals
  • Arrow keys — navigate within radio groups, select lists, date pickers
Test every form end-to-end with keyboard only — no mouse. If any control is unreachable or unusable, there’s a bug.

Screen reader testing

At minimum, test with:
  • NVDA (free, Windows) + Firefox or Chrome
  • VoiceOver (built-in, macOS) + Safari
  • JAWS (commercial, Windows) + Edge or Chrome if you target enterprise
Read each form field, label, error, and button out loud — everything must be announced clearly and in a logical order.

WCAG 2.1 AA checklist for forms

  • Every input has a programmatically associated label
  • Required fields are marked both visually and via aria-required
  • Error messages are associated with their fields via aria-describedby
  • Error messages are in a live region or announced via role="alert"
  • Focus moves to the first invalid field on failed submit
  • Color contrast is at least 4.5:1 for text, 3:1 for UI components
  • Validation state is shown with more than color alone
  • All controls are reachable and operable by keyboard
  • Focus is visible on every interactive element
  • No keyboard traps (Escape closes modals, focus returns to trigger)
  • Form works at 200% zoom without horizontal scrolling
  • Timeout warnings (if any) give users a way to extend the session

Custom components: accessibility requirements

If you build custom components, you own their accessibility. Minimum:
  • Use semantic HTML (<button>, not <div onClick>)
  • Accept and apply id, aria-*, disabled, required props
  • Handle keyboard events (onKeyDown for custom interactions)
  • Provide visible focus styles
  • Announce state changes via aria-live where appropriate
Example accessible custom input:
const AccessibleInput = ({
  id, label, required, disabled, value, onChange,
  'aria-invalid': invalid,
  'aria-describedby': describedBy,
}) => (
  <div>
    <label htmlFor={id}>
      {label}{required && <span aria-hidden="true"> *</span>}
      {required && <span className="sr-only"> required</span>}
    </label>
    <input
      id={id}
      value={value ?? ''}
      disabled={disabled}
      required={required}
      aria-required={required}
      aria-invalid={invalid}
      aria-describedby={describedBy}
      onChange={(e) => onChange?.(e.target.value)}
    />
  </div>
)

Automated testing

Automated tools catch 30-40% of a11y issues. Use them as a baseline, not a substitute for manual testing.
  • axe-core — integrate via @axe-core/react in dev or jest-axe in tests
  • Lighthouse — built into Chrome DevTools
  • pa11y — CI-friendly a11y scanner
// Example jest-axe test
import { axe } from 'jest-axe'

test('form has no accessibility violations', async () => {
  const { container } = render(<MyForm />)
  expect(await axe(container)).toHaveNoViolations()
})
Last modified on April 16, 2026