Accessible Forms
How to make it easy to create accessible forms
Overview
For form-heavy products, creating accessible forms can be a bit of a challenge. Even if it's easy enough to connect labels to fields, suddenly you're asked to add descriptions and error messages and the code gets too verbose with ids and attributes everywhere.
This guide offers an alternative approach where most of the usual connections between form elements, labels, descriptions and error messages are handled automatically.
Why a guide?
- Simple products can get by with a simpler approach
- It's not a one-size-fits-all solution
- It's best if you understand what's going on instead of having this functionality baked in the components by default
Source Code
The idea is to create a Field component that will act basically as a Provider and exposes attributes and methods to register elements.
Field
This is the main component for creating accessible forms. It will automatically connect labels, descriptions and error messages to their appropriate fields. It will also allow you to easily improve your form components by adding the appropriate attributes.
"use client";
import {
createContext,
Fragment,
useCallback,
use,
useId,
useMemo,
useState,
} from "react";
interface FieldContextType {
id: string;
"aria-errormessage"?: string;
"aria-describedby"?: string;
"aria-labelledby"?: string;
registerElement: (
type: "error" | "description" | "label",
id: string
) => () => void;
}
const FieldContext = createContext<FieldContextType | undefined>(undefined);
const useField = () => use(FieldContext);
interface FieldProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
}
const Field = ({ children, className, ...props }: FieldProps) => {
const id = useId();
const [errorIds, setErrorIds] = useState<string[]>([]);
const [descriptionIds, setDescriptionIds] = useState<string[]>([]);
const [labelIds, setLabelIds] = useState<string[]>([]);
const registerErrorElement = useCallback((id: string) => {
setErrorIds((prev) => [...prev, id]);
const unregister = () => {
setErrorIds((prev) => prev.filter((prevId) => prevId !== id));
};
return unregister;
}, []);
const registerDescriptionElement = useCallback((id: string) => {
setDescriptionIds((prev) => [...prev, id]);
const unregister = () => {
setDescriptionIds((prev) => prev.filter((prevId) => prevId !== id));
};
return unregister;
}, []);
const registerLabelElement = useCallback((id: string) => {
setLabelIds((prev) => [...prev, id]);
const unregister = () => {
setLabelIds((prev) => prev.filter((prevId) => prevId !== id));
};
return unregister;
}, []);
const registerElement = useCallback(
(type: "error" | "description" | "label", id: string) => {
switch (type) {
case "error":
return registerErrorElement(id);
case "description":
return registerDescriptionElement(id);
case "label":
return registerLabelElement(id);
}
},
[registerErrorElement, registerDescriptionElement, registerLabelElement]
);
const ariaErrormessage = useMemo(() => {
return errorIds.length > 0 ? errorIds.join(" ") : undefined;
}, [errorIds]);
const ariaDescribedby = useMemo(() => {
return descriptionIds.length > 0 ? descriptionIds.join(" ") : undefined;
}, [descriptionIds]);
const ariaLabelledby = useMemo(() => {
return labelIds.length > 0 ? labelIds.join(" ") : undefined;
}, [labelIds]);
const ctx = useMemo(
() => ({
registerElement,
id,
"aria-errormessage": ariaErrormessage,
"aria-describedby": ariaDescribedby,
"aria-labelledby": ariaLabelledby,
}),
[registerElement, id, ariaErrormessage, ariaDescribedby, ariaLabelledby]
);
/**
* If it's being used just as a wrapper around components, treat it as a fragment (kind of like a provider) to avoid changing the layout of the DOM structure.
* On the other hand, if it has props like `className`, treat it as a div.
*/
const hasOnlyChildren =
Object.keys(props).length === 1 && "children" in props;
const Comp = hasOnlyChildren ? Fragment : "div";
return (
<FieldContext value={ctx}>
<Comp className={className} {...props}>
{children}
</Comp>
</FieldContext>
);
};
export { Field, useField };
FieldError
Field errors hooks up to the Field's context and will automatically register itself as an error message.
"use client";
import { useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { useField } from "./field";
const FieldError = ({
children,
className,
...props
}: React.ComponentPropsWithRef<"p">) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const fieldCtx = useField();
useEffect(() => {
if (!fieldCtx || !children) return;
const unregister = fieldCtx.registerElement("error", id);
return unregister;
}, [fieldCtx, id, children]);
if (!children) return null;
return (
<p
className={cn("text-base font-medium text-red-500", className)}
id={id}
role="alert"
{...props}
>
{children}
</p>
);
};
export { FieldError };
FieldDescription
Field descriptions hooks up to the Field's context and will automatically register itself as a description.
"use client";
import { useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { useField } from "./field";
const FieldDescription = ({
children,
className,
...props
}: React.ComponentPropsWithRef<"p">) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const fieldCtx = useField();
useEffect(() => {
if (!fieldCtx) return;
const unregister = fieldCtx.registerElement("description", id);
return unregister;
}, [fieldCtx, id]);
return (
<p
className={cn(
"text-foreground-secondary text-base font-medium",
className
)}
id={id}
{...props}
>
{children}
</p>
);
};
export { FieldDescription };
Preparing your components
Form elements
Add the following code yo your input.tsx
, textarea.tsx
, checkbox.tsx
, switch.tsx
, and similar components.
Warning: If you have custom components that use buttons instead of the native elements, you should ommit the
aria-errormessage
andaria-invalid
attributes. See Custom Selects below.
const Input = ({ invalid: propsInvalid, id, ...props }: InputProps) => {
const fieldCtx = useField();
const invalid =
propsInvalid || !!fieldCtx?.["aria-errormessage"] || undefined;
return (
<input
id={id ?? fieldCtx?.id}
aria-errormessage={fieldCtx?.["aria-errormessage"]}
aria-describedby={fieldCtx?.["aria-describedby"]}
aria-labelledby={fieldCtx?.["aria-labelledby"]}
aria-invalid={invalid}
{...props}
/>
);
};
Custom Selects
If you are using a custom Select
component that uses a button instead of a native select element, you should ommit the aria-errormessage
and aria-invalid
attributes as they're reserved for form elements that can have validation errors (such as input
, textarea
and select
):
const SelectTrigger = ({ children, id, ...props }: SelectTriggerProps) => {
const fieldCtx = useField();
const invalid = ctx.invalid || fieldCtx?.["aria-errormessage"] || undefined;
return (
<SelectButton
id={id ?? fieldCtx?.id}
aria-describedby={fieldCtx?.["aria-describedby"]}
aria-labelledby={fieldCtx?.["aria-labelledby"]}
{...props}
>
{children}
</SelectButton>
);
};
Radio and Radio Groups
Radio buttons are a bit special as they need to be grouped together, so the aria attributes should be set on the RadioGroup
component instead:
interface RadioGroupProps extends React.ComponentPropsWithRef<"div"> {
required?: boolean;
}
const RadioGroup = ({
children,
className,
required,
...props
}: RadioGroupProps) => {
const fieldCtx = useField();
return (
<div
role="radiogroup"
aria-describedby={fieldCtx?.["aria-describedby"]}
aria-errormessage={fieldCtx?.["aria-errormessage"]}
aria-labelledby={fieldCtx?.["aria-labelledby"]}
aria-required={required}
className={cn("group", className)}
{...props}
>
{children}
</div>
);
};
Given that we'll have a nested structure, we'll also need some attributes on the Radio
itself.
<Field>
<Label>Communication preferences</Label>
<RadioGroup>
<Field>
<Radio />
<Label>Email</Label>
</Field>
<Field>
<Radio />
<Label>Phone</Label>
</Field>
</RadioGroup>
</Field>
We'll just add the id
and aria-describedby
attribute:
const Radio = ({
className,
id,
...props
}: Omit<React.ComponentPropsWithRef<"input">, "type">) => {
const fieldCtx = useField();
return (
<input
type="radio"
id={id ?? fieldCtx?.id}
aria-describedby={fieldCtx?.["aria-describedby"]}
className={radioStyle({ className })}
{...props}
/>
);
};
Label
Your Label
component needs a bit more work to get associated with a field.
Let's use the Field's registerElement
function:
"use client";
import { useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { useField } from "@/components/field";
const Label = ({
children,
className,
...props
}: React.ComponentPropsWithRef<"label">) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const fieldCtx = useField();
useEffect(() => {
if (!fieldCtx) return;
const unregister = fieldCtx.registerElement("label", id);
return unregister;
}, [fieldCtx, id]);
return (
<label
className={cn("text-foreground text-base font-medium", className)}
htmlFor={fieldCtx?.id}
id={id}
{...props}
>
{children}
</label>
);
};
export { Label };
Fieldset and Legend
A fieldset
is an HTML element used to group related elements within a form, providing a semantic way to organize form controls. It is often used in conjunction with the <legend>
element, which provides a caption for the group.
This grouping not only helps with visual organization but also improves accessibility by allowing screen readers to understand the relationship between grouped elements.
Here we create a Fieldset
component that will automatically register the Legend
elements and expose them to the Field
context.
"use client";
import {
createContext,
useCallback,
useEffect,
use,
useId,
useMemo,
useState,
} from "react";
import { cn } from "@/lib/utils";
interface FieldsetContextValue {
registerLegendElement: (id: string) => () => void;
}
const FieldsetContext = createContext<FieldsetContextValue | null>(null);
const useFieldset = () => use(FieldsetContext);
const Fieldset = ({
children,
className,
...props
}: React.ComponentPropsWithRef<"fieldset">) => {
const [legendIds, setLegendIds] = useState<string[]>([]);
const registerLegendElement = useCallback((id: string) => {
setLegendIds((prev) => [...prev, id]);
const unregister = () => {
setLegendIds((prev) => prev.filter((prevId) => prevId !== id));
};
return unregister;
}, []);
const ctx = useMemo(
() => ({
registerLegendElement,
}),
[registerLegendElement]
);
return (
<FieldsetContext value={ctx}>
<fieldset
aria-labelledby={legendIds.length > 0 ? legendIds.join(" ") : undefined}
className={cn(
"border-border space-y-6 not-first:border-t not-first:pt-6",
className
)}
{...props}
>
{children}
</fieldset>
</FieldsetContext>
);
};
const Legend = ({
children,
className,
...props
}: React.ComponentPropsWithRef<"legend">) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const ctx = useFieldset();
useEffect(() => {
if (!ctx?.registerLegendElement) return;
const unregister = ctx?.registerLegendElement(id);
return unregister;
}, [ctx, id]);
return (
<div
id={id}
className={cn("text-foreground text-base font-semibold", className)}
{...props}
>
{children}
</div>
);
};
export { Fieldset, Legend };
Usage
These examples are using the
react-hook-form
library for the sake of familiarity.
Just wrap your form elements in a Field
component:
<Field>
<Label>Company</Label>
<Input {...register("company")} />
</Field>
You can add a FieldDescription
and FieldError
to the Field
component:
<Field>
<div>
<Label>Name</Label>
<FieldDescription>
This is a description very very long description
</FieldDescription>
</div>
<Input {...register("name", { required: true })} />
<FieldError>{errors.name?.message}</FieldError>
</Field>
Using Fieldset
, Legend
, and RadioGroup
:
<Fieldset>
<Legend>Communication</Legend>
<Field>
<Label>Contact preference</Label>
<RadioGroup required>
<Field>
<Radio {...register("preference", { required: true })} value="email" />
<Label>Email</Label>
</Field>
<Field>
<Radio {...register("preference", { required: true })} value="phone" />
<Label>Phone</Label>
</Field>
</RadioGroup>
<FieldError />
</Field>
<div>
<Field>
<div>
<Checkbox {...register("terms", { required: true })} />
<Label>Accept terms and conditions</Label>
</div>
<FieldError>{errors.terms?.message}</FieldError>
</Field>
<Field>
<div>
<Checkbox {...register("newsletter")} />
<Label>Subscribe to newsletter</Label>
</div>
<FieldError>{errors.newsletter?.message}</FieldError>
</Field>
</div>
</Fieldset>
And, finally, a more complete example with everything working together: