Building Accessible Forms with Gwan

Accessible forms are harder to build than they look. Labels need to be wired to inputs. Errors need to be announced to screen readers the moment they appear. Keyboard users need to navigate dropdowns without a mouse. Focus needs to be trapped inside modals. Gwan's form components handle all of this automatically — this post walks through what's built in, how it works, and how to get the most out of it.
Accessibility means building products that everyone can use — including people who rely on screen readers, keyboard-only navigation, switch controls, or other assistive technologies. It covers visual, motor, auditory, and cognitive impairments.
The shorthand a11y is a numeronym: there are 11 letters between the a and the y in "accessibility". You'll see it widely used in the developer community alongside i18n (internationalisation) and l10n (localisation).
The web standard for accessibility is WCAG (Web Content Accessibility Guidelines), published by the W3C. Most products aim for WCAG 2.1 AA compliance as the baseline.
W3C — Intro to Accessibility →WCAG 2.1 Quick Reference →The A11y Project →MDN Accessibility Guide →
Labels and inputs
Every Gwan form component — Input, TextArea, SelectDropdown, MultiSelect, SearchInput, DatePicker, ColorPicker, Checkbox, RadioButton — wires up the label automatically. You pass a label prop and the component creates a <label> element with the correct htmlFor/id pair:
<Input
label="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>The id is derived from the label text, so label="Email address" becomes id="gwan-input-email-address". Screen readers announce the label when the input receives focus — no manual plumbing required.
Error announcements
When isError={true} is set, three things happen at once:
aria-invalid="true"is added to the input — signals the invalid state to assistive techaria-describedbylinks the input to the error message element- The error element gets
role="alert"— screen readers announce it immediately, without the user having to re-focus the field
<Input
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
isError={!isValidEmail(email)}
errorMessage="Please enter a valid email address."
/><!-- What renders -->
<input
id="gwan-input-email"
aria-invalid="true"
aria-describedby="gwan-input-email-error"
/>
<p id="gwan-input-email-error" role="alert">
Please enter a valid email address.
</p>The role="alert" means a screen reader user typing in the field hears the error as soon as it appears — they don't need to Tab away and back.
Required fields
<Input
label="Full name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>Both required (native HTML validation) and aria-required="true" are applied to the input. The label also renders a * suffix. Screen readers announce "Full name, required, edit text."
Keyboard navigation
All Gwan form components are keyboard accessible:
| Component | Keyboard behaviour |
|---|---|
Input, TextArea | Standard text input; Tab moves focus |
Checkbox | Space toggles checked state |
RadioButton | Space selects; use name prop to enable arrow-key group navigation |
SelectDropdown | Enter/Space opens; ↑↓ navigate options; Enter confirms; Escape closes |
MultiSelect | Enter/Space opens; ↑↓ navigate options; Enter toggles; Escape closes |
DatePicker | Tab into trigger, Enter opens calendar |
Modal | Escape closes; Tab/Shift+Tab cycle only through elements inside the modal |
SelectDropdown in action
<SelectDropdown
label="Country"
options={countries}
value={country}
onChange={setCountry}
/>When focused, pressing ↓ opens the dropdown and highlights the first option. Arrow keys move the highlight. Enter selects. Escape dismisses. The trigger carries role="combobox", aria-expanded, aria-haspopup="listbox", and aria-activedescendant so screen readers announce the currently highlighted option as you navigate.
MultiSelect in action
<MultiSelect
label="Skills"
options={skillOptions}
value={selectedSkills}
onChange={setSelectedSkills}
placeholder="Select skills…"
/>The trigger has role="combobox" and aria-multiselectable="true" on the listbox. Each option carries role="option" and aria-selected. The built-in search field auto-focuses when the dropdown opens, so keyboard users can type to filter immediately.
RadioButton groups
Use the name prop to group radio buttons. This enables arrow-key navigation between options — a browser-native behaviour that only works when all radios in the group share the same name:
<fieldset>
<legend className="text-sm font-semibold text-foreground mb-3">
Notification preference
</legend>
<RadioButton
name="notifications"
label="Email"
value="email"
selectedValue={pref}
onChange={setPref}
/>
<RadioButton
name="notifications"
label="SMS"
value="sms"
selectedValue={pref}
onChange={setPref}
/>
<RadioButton
name="notifications"
label="None"
value="none"
selectedValue={pref}
onChange={setPref}
/>
</fieldset>The <fieldset> + <legend> combination means a screen reader user hears "Notification preference — Email, radio button, 1 of 3" when they focus the first option.
Focus management in modals
When a Modal opens, focus automatically moves to the first focusable element inside it. Tab and Shift+Tab cycle only through elements within the modal — focus cannot escape. When the modal closes, focus returns to the element that triggered the open.
function DeleteUserModal({ open, onClose, userName }) {
return open ? (
<Modal title="Delete user?" onClear={onClose}>
<p className="text-sm text-muted-fg mb-4">
<strong>{userName}</strong> will be permanently removed.
</p>
<div className="flex gap-3 justify-end">
<Button
variant={BUTTON_VARIANTS.SECONDARY}
label="Cancel"
onClick={onClose}
/>
<Button
variant={BUTTON_VARIANTS.PRIMARY}
label="Delete"
onClick={handleDelete}
/>
</div>
</Modal>
) : null;
}The modal carries role="dialog", aria-modal="true", and aria-labelledby pointing to the title. Screen readers announce "Delete user?, dialog" when it opens.
A complete accessible form
Putting it together — a sign-up form that a keyboard or screen reader user can complete entirely without a mouse:
"use client";
import { useState } from "react";
import {
Input,
TextArea,
SelectDropdown,
Checkbox,
Button,
BUTTON_VARIANTS,
} from "gwan-design-system";
const roles = [
{ value: "engineer", label: "Engineer" },
{ value: "designer", label: "Designer" },
{ value: "pm", label: "Product Manager" },
];
export default function SignUpForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState("");
const [bio, setBio] = useState("");
const [agreed, setAgreed] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = "Name is required.";
if (!email.includes("@")) e.email = "Enter a valid email address.";
if (!role) e.role = "Please select a role.";
if (!agreed) e.terms = "You must accept the terms.";
return e;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length) {
setErrors(errs);
return;
}
// submit…
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5 max-w-md">
<Input
label="Full name"
value={name}
onChange={(e) => setName(e.target.value)}
required
isError={!!errors.name}
errorMessage={errors.name}
/>
<Input
label="Work email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
isError={!!errors.email}
errorMessage={errors.email}
/>
<SelectDropdown
label="Role"
options={roles}
value={role}
onChange={setRole}
required
isError={!!errors.role}
errorMessage={errors.role}
/>
<TextArea
label="Short bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="Tell us a little about yourself…"
/>
<Checkbox
label="I accept the terms and conditions"
checked={agreed}
onChange={setAgreed}
/>
{errors.terms && (
<p role="alert" className="text-danger text-xs">
{errors.terms}
</p>
)}
<Button
type="submit"
variant={BUTTON_VARIANTS.PRIMARY}
label="Create account"
onClick={() => {}}
/>
</form>
);
}Every field is labelled, every error is announced, and the submit button is reachable via Tab.
What you still need to handle
Gwan handles component-level accessibility. You still own:
- Page structure — use
<main>,<nav>,<header>landmarks correctly - Heading hierarchy — don't skip from
<h1>to<h3> - Tab order — never use
tabIndex > 0; let DOM order control tab flow - Color contrast — the default Gwan palette passes WCAG AA, but verify with a contrast checker after any token changes
- Post-submit focus — after a failed submission, move focus to the first errored field or an error summary at the top:
const firstErrorRef = useRef<HTMLDivElement>(null);
// After validation fails:
firstErrorRef.current?.focus();- Form-level errors — for multi-step or complex flows, add a summary region:
{
hasErrors && (
<div
role="alert"
aria-live="assertive"
className="p-4 bg-danger-bg border border-danger rounded text-danger-fg text-sm"
>
Please fix the {errorCount} error{errorCount > 1 ? "s" : ""} below before
continuing.
</div>
);
}Testing accessibility
The fastest way to catch issues before they reach users:
npm install -D @axe-core/react// In your root layout or _app.tsx — development only
if (process.env.NODE_ENV !== "production") {
const axe = await import("@axe-core/react");
axe.default(React, ReactDOM, 1000);
}Axe logs violations directly to the browser console in real time. Run it alongside your existing test suite and it will catch missing labels, incorrect ARIA roles, and contrast failures automatically.
Beyond axe, the most valuable test is to unplug your mouse and complete a form entirely with keyboard alone. If you can Tab to every field, enter values, trigger errors, and submit — your form is accessible.
Share this article
Written by
Nimesh Fonseka