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:
| Property | Accessibility use |
|---|---|
id | htmlFor/id pairing. Unique per repeatable row (items[k0].price), so aria-describedby ids never collide. |
props | Your component props (label, help text, …) |
field | Binding: value, onChange, onBlur, error, touched, disabled, isValidating |
conditions | Live required / disabled / readonly flags → aria-required, disabled, aria-readonly |
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-invalidandrole="alert"onfield.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
requiredandreadonlyfromconditions, 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:
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:
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:
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 matchinghtmlFor/id - Error messages use
role="alert"oraria-live="assertive" - Invalid fields have
aria-invalid="true", gated ontouched - Required fields have
aria-required="true"(fromconditions.required) and a visual indicator - Help text is linked via
aria-describedby - Form-level errors are announced (
useFormErrors()banner withrole="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
Comparison with Other Libraries
An honest comparison of RilayKit with React Hook Form, Formik, and TanStack Form — understand the trade-offs and when to choose each.
Internationalization
Patterns for building multilingual forms and workflows with RilayKit — localized labels, validation messages, and dynamic form generation.