Agents (llms.txt)
Octocat

Field

Wires labels, descriptions and errors to form controls with the right ARIA attributes.

We'll never share your email with anyone else.

import { Field } from '@/components/field';
import { Input } from '@/components/input';

export default function FieldPreview() {
  return (
    <Field className="w-72">
      <Field.Label>Email</Field.Label>
      <Field.Control>
        <Input type="email" placeholder="name@example.com" />
      </Field.Control>
      <Field.Description>
        We'll never share your email with anyone else.
      </Field.Description>
    </Field>
  );
}

Dependencies

Source Code

import {
  createContext,
  use,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useState,
} from 'react';
import { Slot } from '@/components/slot';
import { cn } from '@/lib/utils/classnames';

interface FieldContextValue {
  controlId: string;
  labelId: string;
  describedBy: string | undefined;
  invalid: boolean | undefined;
  registerMessage: (id: string) => () => void;
}

const FieldContext = createContext<FieldContextValue | null>(null);

const useFieldContext = () => {
  const ctx = use(FieldContext);
  if (!ctx) {
    throw new Error('Field components must be used within a <Field />');
  }
  return ctx;
};

interface FieldProps extends React.ComponentPropsWithRef<'div'> {
  /**
   * Marks the wrapped control as invalid. Wire this to your validation source
   * (RHF, Zod, server response, plain useState — Field doesn't care).
   */
  invalid?: boolean;
}

const Field = ({ invalid, className, children, ...props }: FieldProps) => {
  const controlId = useId();
  const labelId = useId();
  const [messageIds, setMessageIds] = useState<string[]>([]);

  const registerMessage = useCallback((id: string) => {
    setMessageIds((prev) => [...prev, id]);
    return () => {
      setMessageIds((prev) => prev.filter((existing) => existing !== id));
    };
  }, []);

  const describedBy = messageIds.length > 0 ? messageIds.join(' ') : undefined;

  const ctx = useMemo<FieldContextValue>(
    () => ({ controlId, labelId, describedBy, invalid, registerMessage }),
    [controlId, labelId, describedBy, invalid, registerMessage]
  );

  return (
    <FieldContext value={ctx}>
      <div
        className={cn('flex flex-col gap-1.5', className)}
        data-invalid={invalid || undefined}
        {...props}
      >
        {children}
      </div>
    </FieldContext>
  );
};

const FieldLabel = ({
  className,
  ...props
}: React.ComponentPropsWithRef<'label'>) => {
  const { controlId, labelId } = useFieldContext();
  return (
    <label
      htmlFor={controlId}
      id={labelId}
      className={cn('font-medium text-base text-foreground', className)}
      {...props}
    />
  );
};

interface FieldControlProps {
  children: React.ReactNode;
}

const FieldControl = ({ children }: FieldControlProps) => {
  const { controlId, labelId, describedBy, invalid } = useFieldContext();
  return (
    <Slot
      id={controlId}
      aria-labelledby={labelId}
      aria-describedby={describedBy}
      aria-invalid={invalid || undefined}
    >
      {children}
    </Slot>
  );
};

const FieldDescription = ({
  className,
  children,
  ...props
}: React.ComponentPropsWithRef<'p'>) => {
  const generatedId = useId();
  const id = props.id ?? generatedId;
  const { registerMessage } = useFieldContext();
  const hasContent = !!children;

  useEffect(() => {
    if (!hasContent) return;
    return registerMessage(id);
  }, [id, hasContent, registerMessage]);

  if (!hasContent) return null;

  return (
    <p
      id={id}
      className={cn('text-foreground-secondary text-sm', className)}
      {...props}
    >
      {children}
    </p>
  );
};

const FieldError = ({
  className,
  children,
  ...props
}: React.ComponentPropsWithRef<'p'>) => {
  const generatedId = useId();
  const id = props.id ?? generatedId;
  const { registerMessage } = useFieldContext();
  const hasContent = !!children;

  useEffect(() => {
    if (!hasContent) return;
    return registerMessage(id);
  }, [id, hasContent, registerMessage]);

  if (!hasContent) return null;

  return (
    <p
      id={id}
      role="alert"
      className={cn('text-error text-sm', className)}
      {...props}
    >
      {children}
    </p>
  );
};

const CompoundField = Object.assign(Field, {
  Label: FieldLabel,
  Control: FieldControl,
  Description: FieldDescription,
  Error: FieldError,
});

export { CompoundField as Field };

Overview

Field is the connective tissue between a label, a control, a description and an error. It wires the right id / aria-labelledby / aria-describedby / aria-invalid attributes onto the control without you having to keep track of ids by hand, and without forking every form primitive to consume a context.

The base form components (Input, Textarea, Checkbox, Radio, Select, Switch) stay dumb on purpose — they don’t know about Field. When you want accessible wiring, you reach for Field. When you don’t, native form controls work as-is.

Anatomy


          <Field>
  <Field.Label />
  <Field.Control>
    <input />
  </Field.Control>
  <Field.Description />
  <Field.Error />
</Field>
        

Field.Control is a Slot that injects ARIA attrs into its single child. The child can be any form control that spreads its props — Foundations primitives like Input, Textarea, Checkbox, Radio, Select and Switch all work directly.

API Reference

Field

The root component. Provides context for the rest, generates ids, and lays its children out as a flex column with a small gap so you don’t have to add spacing by hand.

Prop Default Type Description
invalid - boolean Marks the wrapped control as invalid. `aria-invalid` is set on the control and `data-invalid` on the root for styling. Wire this to your validation source — RHF, Zod, server response, plain useState. Field doesn't care.
className - string Forwarded to the root `<div>`.

Field.Label

A <label> with htmlFor and id taken from context.

Field.Control

Wraps its single child (a form control) and injects id, aria-labelledby, aria-describedby and aria-invalid.

Field.Description

Renders a <p> and registers its id into the field’s aria-describedby array. You can render multiple descriptions — they’ll all be linked.

Field.Error

Same as Field.Description, but only renders (and only registers) when its children are truthy. Adds role="alert" so screen readers announce changes. Pair with invalid on the root for the full picture.

ARIA reasoning

Every relationship is expressed via attributes a screen reader actually consumes:

  • <label htmlFor> and aria-labelledby both point to the label, giving screen readers a name for the control on focus.
  • aria-describedby carries a space-separated list of message ids — both descriptions and errors. When an error appears or disappears, its id is added to or removed from the list automatically.
  • aria-invalid flips on when invalid={true}.

We use aria-describedby for errors instead of aria-errormessage deliberately. aria-errormessage has weaker screen-reader support (especially older versions of NVDA and JAWS) and the mental model is simpler with one list of messages — descriptions and errors compose the same way.

Examples

Basic

A label, an input and a description.

We'll never share your email with anyone else.

import { Field } from '@/components/field';
import { Input } from '@/components/input';

export default function FieldPreview() {
  return (
    <Field className="w-72">
      <Field.Label>Email</Field.Label>
      <Field.Control>
        <Input type="email" placeholder="name@example.com" />
      </Field.Control>
      <Field.Description>
        We'll never share your email with anyone else.
      </Field.Description>
    </Field>
  );
}

With error

The error appears (and registers itself in aria-describedby) only while validation fails.

import { useState } from 'react';
import { Field } from '@/components/field';
import { Input } from '@/components/input';

export default function FieldErrorPreview() {
  const [value, setValue] = useState('not-an-email');
  const error =
    value.length > 0 && !value.includes('@')
      ? 'Please enter a valid email address.'
      : '';

  return (
    <Field invalid={!!error} className="w-72">
      <Field.Label>Email</Field.Label>
      <Field.Control>
        <Input
          type="email"
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder="name@example.com"
        />
      </Field.Control>
      <Field.Error>{error}</Field.Error>
    </Field>
  );
}

Radio group

Native <fieldset> and <legend> group the radios; one Field per option carries the per-option label and description. The radio’s name attribute keeps them mutually exclusive.

Plan

For personal projects.

$12/mo. For small teams.

Custom pricing. SSO and audit logs.

import { useState } from 'react';
import { Field } from '@/components/field';
import { Radio } from '@/components/radio';

const plans = [
  { value: 'free', label: 'Free', description: 'For personal projects.' },
  { value: 'pro', label: 'Pro', description: '$12/mo. For small teams.' },
  {
    value: 'enterprise',
    label: 'Enterprise',
    description: 'Custom pricing. SSO and audit logs.',
  },
];

export default function FieldRadioGroupPreview() {
  const [plan, setPlan] = useState('free');

  return (
    <fieldset className="flex w-72 flex-col gap-3">
      <legend className="font-medium text-base">Plan</legend>
      {plans.map((p) => (
        <Field key={p.value} className="flex-row items-center gap-3">
          <Field.Control>
            <Radio
              name="plan"
              value={p.value}
              checked={plan === p.value}
              onChange={() => setPlan(p.value)}
            />
          </Field.Control>
          <div className="flex flex-col">
            <Field.Label>{p.label}</Field.Label>
            <Field.Description>{p.description}</Field.Description>
          </div>
        </Field>
      ))}
    </fieldset>
  );
}

With React Hook Form

Pass !!errors.x to invalid and errors.x?.message as Field.Error children. That’s the entire integration. The same pattern works with TanStack Form, Formik, or your own useState-driven validation.

At least 8 characters.

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/button';
import { Field } from '@/components/field';
import { Input } from '@/components/input';

const schema = z.object({
  email: z.string().email('Please enter a valid email address.'),
  password: z.string().min(8, 'Password must be at least 8 characters long.'),
});

type FormValues = z.infer<typeof schema>;

export default function FieldWithRhfPreview() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    mode: 'onTouched',
  });

  const onSubmit = handleSubmit(async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 600));
    console.log('submitted', values);
  });

  return (
    <form onSubmit={onSubmit} className="flex w-80 flex-col gap-4">
      <Field invalid={!!errors.email}>
        <Field.Label>Email</Field.Label>
        <Field.Control>
          <Input type="email" {...register('email')} />
        </Field.Control>
        <Field.Error>{errors.email?.message}</Field.Error>
      </Field>

      <Field invalid={!!errors.password}>
        <Field.Label>Password</Field.Label>
        <Field.Control>
          <Input type="password" {...register('password')} />
        </Field.Control>
        <Field.Description>At least 8 characters.</Field.Description>
        <Field.Error>{errors.password?.message}</Field.Error>
      </Field>

      <Button type="submit" isLoading={isSubmitting}>
        Sign up
      </Button>
    </form>
  );
}

Caveats

Components that aren’t a single form control

Field.Control injects props onto a single child. That works for native inputs and Foundations primitives that wrap them. It doesn’t work cleanly for compound primitives where the actual control isn’t the root element:

  • Slider — the form control is Slider.Thumb (a native <input type="range">). Wrap that, not the root.
  • OTP Input — the root is a container; the inputs are the cells.
  • Listbox — its trigger is a <button> whose attributes are managed by floating-ui, not the rendered DOM at the top of the tree.

For these, either keep them outside Field or wire ARIA manually with the ids you control.

Custom selects

If you build a custom select on top of a <button> (rather than the native <select>), avoid aria-invalid and treating it as a form control for validation purposes — those attributes are reserved for elements that have a native validity state. Use the native <select> (which Foundations exports) when you need form-validation semantics.

Don’t override id / aria-* on Field.Control’s child

Field.Control uses Slot, which lets the child’s explicit props override the injected ones. If you write <Field.Control><Input id="custom" /></Field.Control>, your id wins — and Field.Label’s htmlFor will no longer match. That’s the explicit override path; reserve it for the rare case where you genuinely need a fixed id.

Previous

Drawer

Next

File Upload