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 and aria-invalid attributes. See Custom Selects below.

input.tsx
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):

select.tsx
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:

radio-group.tsx
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:

label.tsx
"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: