rilaykit
Guides

Accessibility

Patterns for building accessible forms and workflows with RilayKit's headless architecture — ARIA attributes, focus management, and keyboard navigation.

Accessibility in a Headless Library

RilayKit generates no HTML and no ARIA attributes — accessibility is your responsibility. In exchange, nothing ever conflicts with your markup, and the render context exposes everything WCAG 2.1 AA needs.

Building Accessible Renderers

Every component renderer receives a ComponentRenderContext:

PropertyAccessibility use
idhtmlFor/id pairing. Unique per repeatable row (items[k0].price), so aria-describedby ids never collide.
propsYour component props (label, help text, …)
fieldBinding: value, onChange, onBlur, error, touched, disabled, isValidating
conditionsLive required / disabled / readonly flags → aria-required, disabled, aria-readonly
components/AccessibleInput.tsx
import type { ComponentRenderContext } from 'rilaykit';

interface InputProps {
  label: string;
  type?: string;
  helpText?: string;
}

export function AccessibleInput({
  id,
  props,
  field,
  conditions,
}: ComponentRenderContext<InputProps>) {
  const errorId = `${id}-error`;
  const helpId = `${id}-help`;
  const hasError = Boolean(field?.touched && field.error?.length);

  return (
    <div>
      <label htmlFor={id}>
        {props.label}
        {conditions?.required && <span aria-hidden="true"> *</span>}
      </label>
      <input
        id={id}
        type={props.type ?? 'text'}
        value={(field?.value as string) ?? ''}
        onChange={(e) => field?.onChange(e.target.value)}
        onBlur={field?.onBlur}
        disabled={field?.disabled}
        readOnly={conditions?.readonly}
        aria-invalid={hasError || undefined}
        aria-required={conditions?.required || undefined}
        aria-describedby={
          [hasError ? errorId : null, props.helpText ? helpId : null]
            .filter(Boolean)
            .join(' ') || undefined
        }
      />
      {props.helpText && <p id={helpId}>{props.helpText}</p>}
      {hasError && (
        <p id={errorId} role="alert">
          {field?.error?.[0]?.message}
        </p>
      )}
    </div>
  );
}

Register it with ril.create().component('text', { renderer: AccessibleInput }).

Key points:

  • htmlFor={id} + id — the single most important form-field requirement.
  • Gate aria-invalid and role="alert" on field.touched. With the default timing (mode: 'onTouched', reValidateMode: 'onChange') errors appear on first blur and clear as the user types, and submit marks errored fields touched — so a touched-gated renderer still shows submit errors.
  • Read required and readonly from conditions, not props: they track conditional rules live.
  • role="alert" makes screen readers announce the error the moment it appears.
  • Selects follow the same pattern. For checkboxes, wrap the input inside the <label> for a larger click target.

Form-Level Errors and Error Summary

Cross-field issues whose path names a field surface through that field's field.error. Issues targeting no field land in the reserved __form__ bucket — read it with useFormErrors() for a banner:

components/FormErrorBanner.tsx
import { useFormErrors } from 'rilaykit/react';

export function FormErrorBanner() {
  const errors = useFormErrors();
  if (errors.length === 0) return null;

  return (
    <div role="alert">
      {errors.map((e) => (
        <p key={e.message}>{e.message}</p>
      ))}
    </div>
  );
}

For a summary that links each error to its field, read the whole error map from the store. Skip FORM_LEVEL_ERROR_KEY — it has no matching element to link to:

components/ErrorSummary.tsx
import { FORM_LEVEL_ERROR_KEY } from 'rilaykit';
import { useFormStoreApi } from 'rilaykit/react';
import { useStore } from 'zustand';

export function ErrorSummary() {
  const store = useFormStoreApi();
  const errors = useStore(store, (state) => state.errors);

  const fieldIds = Object.keys(errors).filter(
    (fieldId) => fieldId !== FORM_LEVEL_ERROR_KEY && errors[fieldId].length > 0
  );
  if (fieldIds.length === 0) return null;

  return (
    <div role="alert" aria-labelledby="error-summary-title">
      <h2 id="error-summary-title">There are errors in your form</h2>
      <ul>
        {fieldIds.map((fieldId) => (
          <li key={fieldId}>
            <a href={`#${fieldId}`}>{errors[fieldId][0].message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Place both inside <Form>, before the body. The anchor links work because each input's id matches its field id:

<Form of={myForm} onSubmit={handleSubmit}>
  <FormErrorBanner />
  <ErrorSummary />
  <FormBody />
  <FormSubmit>Submit</FormSubmit>
</Form>

Conditional Fields and Live Regions

When a condition shows or hides a field, sighted users see the change; screen reader users need an announcement. Wrap conditional areas — and only those areas — in a live region:

<Form of={myForm} onSubmit={handleSubmit}>
  <FormField id="accountType" />
  <div aria-live="polite" aria-atomic="false">
    <FormField id="companyName" />
    <FormField id="companySize" />
  </div>
  <FormSubmit>Submit</FormSubmit>
</Form>

aria-live="polite" waits for the screen reader to finish speaking; aria-atomic="false" announces only the changed content. Never wrap the whole form — announcing every change is overwhelming.

Workflow Step Navigation

Flow.Progress exposes the visible steps via render prop — add the landmark and aria-current="step" yourself:

import { Flow } from 'rilaykit/react';

<Flow.Progress>
  {({ steps, currentIndex, goTo }) => (
    <nav aria-label="Progress">
      <ol>
        {steps.map((step, index) => (
          <li key={step.id}>
            <button
              type="button"
              aria-current={index === currentIndex ? 'step' : undefined}
              disabled={index > currentIndex}
              onClick={() => goTo(index)}
            >
              Step {index + 1}: {step.title}
            </button>
          </li>
        ))}
      </ol>
    </nav>
  )}
</Flow.Progress>

Focus Management

On step change, move focus to the first focusable element of the new step — otherwise keyboard users are stranded on the button they just clicked:

components/step-focus.tsx
import { useEffect, useRef } from 'react';
import { useFlowStepIndex } from 'rilaykit/react';

export function StepFocus({ children }: { children: React.ReactNode }) {
  const stepIndex = useFlowStepIndex();
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    ref.current?.querySelector<HTMLElement>('input, select, textarea')?.focus();
  }, [stepIndex]);

  return <div ref={ref}>{children}</div>;
}
<Flow of={onboarding} onComplete={handleComplete}>
  <StepFocus>
    <Flow.Body />
  </StepFocus>
  <Flow.Back />
  <Flow.Next />
</Flow>

If steps animate in, delay the focus call until the transition ends (requestAnimationFrame or a timeout matching the animation duration).

Checklist

  • Every field has a visible <label> with matching htmlFor/id
  • Error messages use role="alert" or aria-live="assertive"
  • Invalid fields have aria-invalid="true", gated on touched
  • Required fields have aria-required="true" (from conditions.required) and a visual indicator
  • Help text is linked via aria-describedby
  • Form-level errors are announced (useFormErrors() banner with role="alert")
  • Conditional fields are wrapped in aria-live="polite" regions
  • Workflow navigation uses aria-current="step"
  • Focus moves to the first field on step change
  • All interactive elements are keyboard accessible
  • Color is never the only error indicator

On this page