FormField

Label, hint, and error wrapper for controls that don't wire their own accessibility.

moleculesSource
import { FormField } from '@elirobinson/react/components/molecules/FormField';

Styles: @elirobinson/react/styles/molecules/FormField.css — already included when you import @elirobinson/react/styles.css.

Shown on client invoices.

Show code
import { FormField } from '@elirobinson/react/components/molecules/FormField';

export default function Basic() {
  return (
    <FormField label="Studio name" htmlFor="studio-name" hint="Shown on client invoices.">
      {(fieldProps) => <input id="studio-name" className="ds-input" {...fieldProps} />}
    </FormField>
  );
}

When to use it

Use FormField when you're labelling a control that doesn't wire its own label, hint, and error — a plain <input>, a native <select>, a third-party widget. It owns the <label> and message markup, and hands the control a bundle of accessibility props (aria-describedby, aria-invalid, aria-required) through a render prop.

Never wrap Input or Textarea in FormField. Both already render their own label and message region — nesting them produces two labels for one control. Reach for FormField specifically when the control underneath doesn't do that wiring itself.

With an error

error and hint are mutually exclusive in the rendered output — when both are set, the error wins and the hint is hidden. required adds a visible * next to the label (hidden from screen readers, since aria-required on the control already says it).

Show code
import { FormField } from '@elirobinson/react/components/molecules/FormField';

export default function WithError() {
  return (
    <FormField
      label="Client email"
      htmlFor="client-email"
      error="Enter a valid email address"
      required
    >
      {(fieldProps) => (
        <input id="client-email" type="email" className="ds-input" {...fieldProps} />
      )}
    </FormField>
  );
}

The error renders with role="alert", the same as Input's, Textarea's and Select's, so a message that appears after the user has left the field — a validate-on-blur form — is announced rather than waiting to be discovered. It is only in the page while there is an error, so it is announced when it appears or changes, not repeated on every render.

Something beside the label

labelAside renders after the label on the same row — a status badge, a small action such as a dictation button. It sits beside the <label>, not inside it, so the control's accessible name is still exactly label, and clicking the aside does not focus the control. The row wraps when there is not room for both.

Pre-filled

Copied from this client's last invoice.

Show code
import { Badge } from '@elirobinson/react/components/atoms/Badge';
import { FormField } from '@elirobinson/react/components/molecules/FormField';

export default function LabelAside() {
  return (
    <FormField
      label="Payment terms"
      htmlFor="payment-terms"
      hint="Copied from this client's last invoice."
      labelAside={<Badge variant="signal">Pre-filled</Badge>}
    >
      {(fieldProps) => (
        <textarea id="payment-terms" className="ds-input ds-textarea" {...fieldProps} />
      )}
    </FormField>
  );
}

Wrapping an arbitrary control

FormField's render prop works with anything that accepts aria-describedby, aria-invalid, and aria-required — not just <input>. Here it labels a native <select>.

How long clients have to pay once you send an invoice.

Show code
import { FormField } from '@elirobinson/react/components/molecules/FormField';

export default function CustomControl() {
  return (
    <FormField
      label="Invoice terms"
      htmlFor="invoice-terms"
      hint="How long clients have to pay once you send an invoice."
    >
      {(fieldProps) => (
        <select id="invoice-terms" className="ds-input ds-select" {...fieldProps}>
          <option value="net-15">Net 15</option>
          <option value="net-30">Net 30</option>
          <option value="due-on-receipt">Due on receipt</option>
        </select>
      )}
    </FormField>
  );
}

Props

PropTypeDefaultDescription
htmlForrequiredstring——
labelrequiredstring——
errorstring—Rendered with `role="alert"`, as `Input`'s is, so it is announced when it appears.
hintstring——
labelAsideReactNode—Rendered after the label, on the same row — a status badge, a small action button. It is a sibling of the `<label>`, never inside it, so the control's accessible name stays exactly `label` and a button here does not activate the control.
requiredboolean——

Also accepts all Omit<HTMLAttributes<HTMLDivElement>, 'children'> props.

Accessibility

  • Renders a <Label htmlFor={htmlFor}> and calls children with a { 'aria-describedby', 'aria-invalid', 'aria-required' } bundle — spread that bundle onto the control you render inside.
  • FormField does not generate or set the child's id for you. You're responsible for giving the control an id that matches htmlFor — that's what makes clicking the label focus the field.
  • aria-describedby is only set when hint or error is present; both share one generated id, since only one message renders at a time.
  • The error message has role="alert"; the hint does not. When a hint turns into an error, the error is a new element rather than the hint's element with a role added, so screen readers announce it.
  • labelAside is outside the <label>: it adds nothing to the control's accessible name. A button placed there needs its own accessible name and, as a primary control, its own 44x44 target.
  • The * appended to a required label is aria-hidden="true" — the accessible requirement signal is aria-required="true" on the control itself, not the asterisk.
  • Keyboard: no custom behavior of its own — once the id/htmlFor pair is wired correctly, clicking the label focuses the control via native browser behavior.
  • The ref forwards to the outer <div>.

Do

  • Keep the child id and htmlFor in sync by hand — FormField does not generate one for you.
  • Spread the full fieldProps bundle onto the control, even the props you think you don't need.
  • Reach for FormField specifically for controls that don't label themselves — native <select>, a range input, a third-party widget.
  • Write error text that says what to do, not just that something is wrong.

Don't

  • Nest Input or Textarea inside FormField — they already render their own label and message region.
  • Set both hint and error and expect to see both — only error renders.
  • Skip the id on the child control; without it the label has nothing to point to.
  • Use required without also making the control itself enforce it — the asterisk is a visual cue, not validation.
  • Put a badge or button inside label to sit it beside the label text — use labelAside, which keeps it out of the accessible name.