Field

How to build a field that labels, describes and explains the errors of any form control, works for everyone, feels familiar to every developer and can be checked by your agents.

A field is what turns a form control into a question people can answer. Its label says what to fill in, its hint how, and its error message what went wrong and how to fix it. Each of them is an element of its own, linked to the control by ID, which is why they so often come apart. Think of a label that does nothing when you click it, a hint that screen readers never read because nothing points to it, or an error in red that no one hears about, since its control was never marked invalid.

This page walks you through building one the right way. We'll look at:

User Interface (UI)

How a field looks, from its parts and where they go around each kind of control to its sizes, states and tokens.

User Experience (UX)

How a field behaves for every person and setting, so its control is named, described and marked required or invalid the way assistive technologies expect.

Developer Experience (DX)

How developers use the Field, with one typed API that labels, describes and validates every form control, without changing any of them.

Agentic Experience (AX)

How agents read, fill in and check a field, from its accessibility tree to its contract, its DOM and its tests.

At a glance

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to fill them in as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Field with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Field holds one control: an Input, a Textarea, a Checkbox, a Switch, a Checkbox Group or a Radio Group. It renders the label, hint and error message around it, gives it its id, and sets its aria-describedby, aria-invalid, required and disabled for you. The controls themselves don't change, so everything their own pages say still holds inside a Field.

We'll only use it to send your receipt

<Field
  id="email"
  label="Email address"
  hint="We'll only use it to send your receipt"
  required
>
  <Input type="email" />
</Field>
AspectField
ElementA <div> around the control, with a <label> and a <p> each for the hint and the error, except around a Radio Group, which renders its own
RoleNone of its own, so the control keeps its role, like textbox, checkbox, switch, group or radiogroup
NameIts <label>, linked with for, or a Checkbox's own label, or a group's legend
DescriptionThe hint, then the error, through the control's aria-describedby
StatesRequired, disabled and invalid, which it sets on the control
ControlsInput, Textarea, Checkbox, Switch, Checkbox Group, Radio Group and native text controls
Sizesxs, sm, md, lg and xl, which it passes on to the control, unless it's a native one
PatternNone in the APG, since a native <label> and aria-describedby cover it
WCAG 2.21.3.1, 1.4.1, 1.4.3, 2.4.6, 3.3.1, 3.3.2, 3.3.3, 4.1.2 and 4.1.3, among others

Checklist

Here's every rule in one place, along with where it comes from and how to check it. A field meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Field itself, so they're the ones to check when you build or review one. Usage rules are met by the forms that use it, like writing an error message that says how to fix what's wrong, so they're the ones to check when you review a screen. Both rules need the two to work together.

The basis says where a rule comes from. Many of the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML. The rest are Open Components rules, which are our own, like building every ID from what you pass rather than generating it. Where a rule asks for more than its criterion, its basis says beyond, like marking required and optional fields in words.

You'll find the rules for each layer on its page:

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It reads the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme too.

The Field tells the controls apart by their component, so it imports the reference Checkbox, Switch, Checkbox Group and Radio Group. Point those imports at your own. Any other control is laid out as a text control, with its label above it, so add a control of yours that names itself, like a toggle button group, to kindOf().

The tests are in Field.test.ts, and mount the real controls too. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script lang="ts">
import { Comment, Fragment, Text, cloneVNode, defineComponent, h, isVNode, onMounted, type PropType, type SlotsType, type VNode } from "vue";
import Checkbox from "../checkbox/Checkbox.vue";
import CheckboxGroup from "../checkbox-group/CheckboxGroup.vue";
import RadioGroup from "../radio-group/RadioGroup.vue";
import Switch from "../switch/Switch.vue";

export interface FieldProps {
  /** The control's `id`, which its label, hint and error point at. Without it, the control's own `id`, then its `name`. */
  id?: string;
  /** Names a text control or a Switch, as its <label>. A Checkbox, a Checkbox Group and a Radio Group name themselves, so they don't take one. */
  label?: string;
  /** Help people need before they fill it in, like the format you expect. Screen readers read it after the control's name. */
  hint?: string;
  /** What's wrong and how to fix it. While it's set, the control is invalid, and screen readers say so. */
  error?: string;
  /** Sets the control's native `required`, which screen readers announce. A Checkbox Group never gets it, since HTML can't require one of a group of checkboxes. */
  required?: boolean;
  /** Disables the control, which leaves it out of its form. */
  disabled?: boolean;
  /** The text size, which also goes to a component control, but never a native one. Without it, the control's own. */
  size?: "xs" | "sm" | "md" | "lg" | "xl";
}

/**
 * What the Field renders around a control depends on what it is, which it reads from
 * the control's component, so none of the controls needs to know about the Field:
 *
 * - `text`: an Input, a Textarea or a native <input>, <textarea> or <select>. The label
 *   above it, the hint between them, the error after it.
 * - `checkbox`: a Checkbox, which its own <label> names. The hint and error after it.
 * - `switch`: a Switch. The label before it on the same row, the hint and error under it.
 * - `checkbox-group`: a Checkbox Group, which its <legend> names. The hint and error after it.
 * - `radio-group`: a Radio Group, which renders its own legend, hint and error, so the
 *   Field renders nothing and hands it the props instead.
 */
export type FieldControl = "text" | "checkbox" | "switch" | "checkbox-group" | "radio-group";

/** The one control in the default slot, leaving out comments and blank text. */
function controls(nodes: VNode[] = []): VNode[] {
  return nodes.flatMap((node) => {
    if (node.type === Fragment && Array.isArray(node.children)) return controls(node.children.filter(isVNode));
    if (node.type === Comment || (node.type === Text && !String(node.children).trim())) return [];
    return [node];
  });
}

function kindOf(control: VNode): FieldControl {
  if (control.type === Checkbox) return "checkbox";
  if (control.type === Switch) return "switch";
  if (control.type === CheckboxGroup) return "checkbox-group";
  if (control.type === RadioGroup) return "radio-group";
  return "text";
}

// A native checkbox or radio isn't a text control, and a label above it would read wrong.
const nativeToggle = (control: VNode) =>
  control.type === "input" && ["checkbox", "radio"].includes(String(control.props?.type));

export default defineComponent({
  name: "Field",
  props: {
    id: String,
    label: String,
    hint: String,
    error: String,
    required: Boolean,
    disabled: Boolean,
    size: String as PropType<FieldProps["size"]>,
  },
  slots: Object as SlotsType<{
    /** One control: an Input, a Textarea, a Checkbox, a Switch, a Checkbox Group, a Radio Group or a native text control. */
    default?: () => VNode[];
  }>,
  setup(props, { slots }) {
    // What the last render found in the slot, which the development checks read once it
    // has mounted, since calling the slot outside a render wouldn't track what it reads.
    let found: VNode[] = [];

    onMounted(() => {
      if (!import.meta.env.DEV) return;
      const control = found[0];
      if (found.length !== 1 || !control) {
        console.warn(`[Field] needs exactly one control, and has ${found.length}: give each control a Field of its own.`);
        return;
      }
      const kind = kindOf(control);
      if (nativeToggle(control)) {
        console.warn("[Field] has a native checkbox or radio: use a Checkbox, or a Radio Group, which name themselves.");
      }
      const named = { checkbox: "Checkbox", "checkbox-group": "Checkbox Group", "radio-group": "Radio Group" } as const;
      if (props.label && kind in named) {
        console.warn(`[Field] has a label, but a ${named[kind as keyof typeof named]} names itself: put the text in its label or legend instead.`);
      }
      if (kind !== "radio-group" && !(props.id ?? control.props?.id ?? control.props?.name)) {
        console.warn("[Field] has no id: give it one, or give its control an id or a name, so its label, hint and error can point at it.");
      }
    });

    return () => {
      found = controls(slots.default?.());
      const control = found[0];
      if (!control) return null;
      const kind = kindOf(control);
      const own = control.props ?? {};
      const size = props.size ?? own.size ?? "md";

      // A Radio Group builds its legend, hint and error, and their IDs from its name, so
      // it gets the Field's props, which win over its own, and the Field renders nothing.
      if (kind === "radio-group") {
        return cloneVNode(control, {
          ...(props.hint && { hint: props.hint }),
          ...(props.error && { error: props.error }),
          ...(props.required && { required: true }),
          ...(props.disabled && { disabled: true }),
          ...(props.size && { size: props.size }),
        });
      }

      // IDs come from what you pass, rather than being made up, so the markup is the
      // same on the server, on the client and in every snapshot, and the label works
      // before any script runs.
      const id: string | undefined = props.id ?? own.id ?? own.name;
      const hintId = props.hint && id ? `${id}-hint` : undefined;
      const errorId = props.error && id ? `${id}-error` : undefined;
      // The hint, then the error, so the error is read after what it's about, then
      // anything the control adds itself, like a Textarea's count.
      const describedBy = [hintId, errorId, own["aria-describedby"]].filter(Boolean).join(" ") || undefined;

      const wired = cloneVNode(control, {
        id,
        "aria-describedby": describedBy,
        ...(props.error && { "aria-invalid": "true" }),
        // HTML has no way to require one of a group of checkboxes, so a Checkbox Group
        // never gets it: say so in the hint, and check it on submit.
        ...(props.required && kind !== "checkbox-group" && { required: true }),
        ...(props.disabled && { disabled: true }),
        // A native control's `size` is a number of characters or rows, so only components get it.
        ...(props.size && typeof control.type !== "string" && { size: props.size }),
      });

      // A Checkbox and a Checkbox Group name themselves, so they get no label.
      const label =
        props.label && (kind === "text" || kind === "switch")
          ? h("label", { "data-slot": "label", for: id }, props.label)
          : null;
      const hint = props.hint ? h("p", { "data-slot": "hint", id: hintId }, props.hint) : null;
      // Rendered only while there's an error, so screen readers never read an empty one.
      // It isn't a live region: people hear it when they reach the control.
      const error = props.error ? h("p", { "data-slot": "error", id: errorId }, props.error) : null;

      return h("div", { class: "field", "data-size": size, "data-control": kind }, [
        label,
        // People read the hint before they type, so it sits between a text control and its label.
        kind === "text" ? hint : null,
        wired,
        kind === "text" ? null : hint,
        error,
      ]);
    };
  },
});
</script>

<style scoped>
/* The same layer order as Tailwind CSS. The field's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .field {
    /* Set per size. The gap and the text sizes are the Radio Group's, so a field and a
       group sit well together in a form. */
    --field--gap: 0.25rem;
    --field--font-size: 0.875rem;
    /* The space between a Switch and its label, on the same row. */
    --field--column-gap: 1rem;
    /* How far a Checkbox's hint and error sit in, so they line up with its label. */
    --field--indent: 1.625rem;

    display: flex;
    flex-direction: column;
    gap: var(--field--gap);
    /* In a flex row, it can get narrower than its control is on its own, rather than overflow. */
    min-width: 0;
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: var(--field--font-size);
    line-height: 1.25;
  }

  /* Sizes */
  .field[data-size="xs"] {
    --field--font-size: 0.75rem;
    --field--indent: 1.25rem;
  }
  .field[data-size="sm"] {
    --field--indent: 1.5rem;
  }
  .field[data-size="lg"] {
    --field--font-size: 1rem;
    --field--indent: 1.75rem;
  }
  .field[data-size="xl"] {
    --field--font-size: 1rem;
    --field--indent: 2.125rem;
  }

  /* Parts */
  .field > [data-slot="label"] {
    /* The Radio Group's legend weight, so labels and legends look alike. */
    font-weight: 500;
  }
  .field > [data-slot="hint"],
  .field > [data-slot="error"] {
    margin: 0;
  }
  .field > [data-slot="hint"] {
    /* The text colour at 70%, which has 4.5:1 against the page, like the Radio Group's hint. */
    color: color-mix(in srgb, currentColor 70%, transparent);
  }
  .field > [data-slot="error"] {
    color: var(--color--error-text);
  }

  /* A Checkbox's hint and error line up with its label, rather than its box. */
  .field[data-control="checkbox"] > [data-slot="hint"],
  .field[data-control="checkbox"] > [data-slot="error"] {
    padding-inline-start: var(--field--indent);
  }

  /* A Switch sits after its label on the same row, with the hint and error under both.
     Logical, so it mirrors in right-to-left layouts. */
  .field[data-control="switch"] {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: center;
    column-gap: var(--field--column-gap);
    row-gap: var(--field--gap);
  }
  .field[data-control="switch"] > [data-slot="hint"],
  .field[data-control="switch"] > [data-slot="error"] {
    grid-column: 1 / -1;
  }

  /* States. Each one reads the control's own attribute, so the label can't look
     disabled while the control isn't. Only the control counts, whether it's native or
     a component's control part, and not a button in an Input's actions. */
  .field:has(> :disabled, > * > [data-slot="control"]:disabled) > [data-slot="label"] {
    opacity: 0.5;
  }

  @media (forced-colors: active) {
    .field:has(> :disabled, > * > [data-slot="control"]:disabled) > [data-slot="label"] {
      color: GrayText;
      opacity: 1;
    }
  }
}
</style>

Sources

Copyright © 2026