# Field

> Connect a form control with its label, instructions, and validation message.

Source: https://www.honestui.com/docs/components/field

```tsx
"use client";

import * as React from "react";

import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldHelperSlot,
  FieldLabel,
  FieldSeparator,
} from "@/components/honest-ui/ui/field";

export function FieldDemo() {
  const [email, setEmail] = React.useState("name@");

  return (
    <FieldGroup className="w-full max-w-xs">
      <Field>
        <FieldLabel>Name</FieldLabel>
        <FieldControl name="name" placeholder="Enter your name" />
        <FieldDescription>Visible on your public profile.</FieldDescription>
      </Field>
      <FieldSeparator>Contact</FieldSeparator>
      <Field invalid={!email.includes("@example.com")}>
        <FieldLabel>Email</FieldLabel>
        <FieldControl
          aria-invalid={!email.includes("@example.com")}
          onChange={(event) => setEmail(event.target.value)}
          type="email"
          value={email}
        />
        <FieldHelperSlot>
          <FieldDescription>Use your example.com address.</FieldDescription>
          <FieldError>Enter an example.com email address.</FieldError>
        </FieldHelperSlot>
      </Field>
    </FieldGroup>
  );
}

```

## Overview [#overview]

Use Field to wrap a form control with a label, description, validation message, and disabled or invalid state. Field owns the wiring that is easy to get wrong by hand: the label is associated with the control automatically, the description and error are attached to the control through `aria-describedby`, and `aria-invalid` is set for you when validation fails.

Field keeps form layout consistent and helps connect supporting text to the control. Compose it with any control: an input, textarea, select, checkbox, radio group, slider, switch, combobox, or a custom element passed through the render prop.

## Anatomy [#anatomy]

A field usually includes a label, control, description, and error message. `FieldHelperSlot` lets description and error occupy the same layout space, `FieldGroup` stacks related fields, and `FieldSeparator` divides a long form without creating a new semantic group.

The root renders a `div` laid out as a column. The control renders an `input` wrapped in a full-width span; pass the `render` prop to substitute any element, which drops the wrapper span. Descriptions render as paragraphs and errors as divs, both sized down and colored through theme tokens. All field parts receive data attributes for their state — `data-invalid`, `data-disabled`, `data-focused` — so styles react to the field rather than to props you thread by hand.

## Behavior [#behavior]

**Validation timing.** `validationMode` defaults to `"onSubmit"`. With Honest UI's native `Form` wrapper there is no Base UI Form context driving submit-time validation, so validation commits happen when the user presses <kbd>Enter</kbd> in the control, on blur when the mode is `"onBlur"`, and on every change when the mode is `"onChange"` (optionally debounced with `validationDebounceTime`). Validate on submission or after a field has been edited; avoid showing an error before a person has had a chance to act.

**Custom rules.** Pass a `validate` function that receives the value and all form values and returns an error string, an array of strings, or `null`. Async functions are supported. Native constraint violations such as `required` or `type="email"` surface their browser messages automatically.

**External ownership.** The `invalid`, `dirty`, and `touched` props let a form library drive state instead. Setting `invalid` colors the control, sets `aria-invalid`, and makes a matching `FieldError` render immediately.

Keep entered values after failure and write each message as a specific recovery instruction.

## Accessibility [#accessibility]

The label is a real `<label>` associated with the control automatically; clicking anywhere in it focuses the control, which gives checkboxes and radios a much larger activation target. You do not need `htmlFor` or matching ids inside a Field.

While a description is rendered, its id is attached to the control's `aria-describedby`. A rendered error joins the same list, and the control receives `aria-invalid="true"`, so screen readers announce the failure state and the instructions together when focus moves to the field. An unrendered `FieldError` contributes nothing to the accessibility tree, which is why errors appear only when they are true.

Keyboard users reach the control with <kbd>Tab</kbd> and edit with normal text keys; pressing <kbd>Enter</kbd> commits validation. The control stands 28 px (`sm`), 32 px (`default`), or 40 px (`lg`) tall, clearing the WCAG 2.2 minimum target size of 24 px; leave extra room around fields that are primary touch targets on mobile.

Colors come from theme tokens, so descriptions, errors, borders, and disabled dimming adapt to dark mode automatically. Control padding uses logical properties, so the layout mirrors in right-to-left locales. The helper slot reserves one space for description and error, so swapping between them does not shift the surrounding form, and its transitions are wrapped in `motion-safe`.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;field&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/field.tsx

```tsx
"use client"

import { Field as FieldPrimitive } from "@base-ui-components/react/field"

import { cn } from "@/lib/utils"

function Field({ className, ...props }: FieldPrimitive.Root.Props) {
  return (
    <FieldPrimitive.Root
      data-slot="field"
      className={cn(
        "flex w-full flex-col gap-[var(--hui-space-2)]",
        className
      )}
      {...props}
    />
  )
}

function FieldLabel({ className, ...props }: FieldPrimitive.Label.Props) {
  return (
    <FieldPrimitive.Label
      data-slot="field-label"
      className={cn("inline-flex items-center gap-2 text-sm/4", className)}
      {...props}
    />
  )
}

function FieldControl({
  className,
  size = "default",
  ...props
}: Omit<FieldPrimitive.Control.Props, "size"> & {
  size?: "sm" | "default" | "lg" | number
}) {
  if (props.render) {
    return (
      <FieldPrimitive.Control
        data-slot="field-control"
        className={className}
        {...props}
      />
    )
  }

  return (
    <span
      data-slot="field-control"
      className={cn(
        "flex w-full flex-col",
        className
      )}
    >
      <FieldPrimitive.Control
        data-slot="field-control"
        className={cn(
          "box-border h-[var(--hui-space-9)] w-full min-w-0 rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-tertiary)] bg-[var(--hui-color-background-base-primary)] px-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] outline-none [font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)] [transition:var(--hui-transition-interactive)] placeholder:text-[var(--hui-color-foreground-base-tertiary)] focus:border-[var(--hui-color-border-accent-emphasis)] data-disabled:cursor-not-allowed data-disabled:opacity-40 data-invalid:border-[var(--hui-color-border-danger-emphasis)] data-invalid:focus:border-[var(--hui-color-border-danger-emphasis-hover)]",
          size === "sm" && "h-[var(--hui-space-8)]",
          size === "lg" && "h-[var(--hui-space-10)]",
          props.type === "search" &&
            "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
          props.type === "file" &&
            "text-muted-foreground file:me-3 file:bg-transparent file:text-sm file:font-medium file:text-foreground"
        )}
        {...props}
      />
    </span>
  )
}

function FieldDescription({
  className,
  ...props
}: FieldPrimitive.Description.Props) {
  return (
    <FieldPrimitive.Description
      data-slot="field-description"
      className={cn(
        "m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]",
        className
      )}
      {...props}
    />
  )
}

function FieldError({ className, ...props }: FieldPrimitive.Error.Props) {
  return (
    <FieldPrimitive.Error
      data-slot="field-error"
      className={cn(
        "m-0 text-[var(--hui-color-foreground-danger-primary)] [font-size:var(--hui-font-size-mini)] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)] starting:opacity-0",
        className
      )}
      {...props}
    />
  )
}

function FieldHelperSlot({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-helper-slot"
      className={cn(
        "grid [&>*]:[grid-area:1/1] [&>[data-slot=field-description][data-invalid]]:invisible [&>[data-slot=field-description][data-invalid]]:opacity-0 motion-safe:[&>*]:[transition:opacity_var(--hui-duration-fast)_var(--hui-ease-out),visibility_var(--hui-duration-fast)_var(--hui-ease-out)]",
        className
      )}
      {...props}
    />
  )
}

const FieldValidity = FieldPrimitive.Validity

function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-group"
      className={cn("flex flex-col gap-4", className)}
      {...props}
    />
  )
}

function FieldSeparator({
  className,
  children,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="field-separator"
      className={cn("relative flex items-center gap-2 py-2", className)}
      {...props}
    >
      <div className="h-px flex-1 bg-border" />
      {children && (
        <span className="text-xs text-muted-foreground">{children}</span>
      )}
      <div className="h-px flex-1 bg-border" />
    </div>
  )
}

export {
  Field,
  FieldLabel,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldHelperSlot,
  FieldValidity,
  FieldGroup,
  FieldSeparator,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldHelperSlot,
  FieldLabel,
} from "@/components/ui/field";
```

```tsx
<Field>
  <FieldLabel>Name</FieldLabel>
  <FieldControl name="name" placeholder="Enter your name" />
  <FieldHelperSlot>
    <FieldDescription>Visible on your public profile.</FieldDescription>
    <FieldError />
  </FieldHelperSlot>
</Field>
```

A bare `<FieldError />` renders the current validation message — from native constraints or your `validate` function — and renders nothing while the field is valid. To show an application-owned message, pass children and set `invalid` yourself, as shown in [Custom validity output](#custom-validity-output).

## Don't do this [#dont-do-this]

### Expecting an error to appear on submit [#expecting-an-error-to-appear-on-submit]

```tsx
// Bad
<Form onSubmit={save}>
  <Field name="email">
    <FieldLabel>Email address</FieldLabel>
    <FieldControl name="email" type="email" required />
    <FieldError>Enter an email address.</FieldError>
  </Field>
</Form>
```

```tsx
// Good
<Form onSubmit={save}>
  <Field name="email" validationMode="onBlur">
    <FieldLabel>Email address</FieldLabel>
    <FieldControl name="email" type="email" required />
    <FieldError />
  </Field>
</Form>
```

With the default `"onSubmit"` mode and a native `Form`, nothing re-validates when the submit button is clicked — the browser shows its own bubble for native constraints, and custom messages never display. Pick a mode that matches when validation should actually run, or drive the field with the `invalid` prop from your own submission logic.

### Replacing the description with the error [#replacing-the-description-with-the-error]

```tsx
// Bad
<Field invalid={hasError}>
  <FieldLabel>Password</FieldLabel>
  <FieldControl type="password" />
  {hasError ? (
    <FieldError>Use at least 12 characters.</FieldError>
  ) : (
    <FieldDescription>Use at least 12 characters.</FieldDescription>
  )}
</Field>
```

```tsx
// Good
<Field invalid={hasError}>
  <FieldLabel>Password</FieldLabel>
  <FieldControl type="password" />
  <FieldHelperSlot>
    <FieldDescription>Use at least 12 characters.</FieldDescription>
    <FieldError>Choose a longer password.</FieldError>
  </FieldHelperSlot>
</Field>
```

Swapping one message for another removes the requirement at the exact moment someone needs to reread it, and the two texts rarely say the same thing. The helper slot keeps both in the accessibility tree's rotation and reserves their shared space, so nothing shifts when the error appears.

### Marking a field required but disabled [#marking-a-field-required-but-disabled]

```tsx
// Bad
<Field>
  <FieldLabel>VAT number *</FieldLabel>
  <FieldControl name="vat" required disabled={!isBusiness} />
</Field>
```

```tsx
// Good
{isBusiness ? (
  <Field>
    <FieldLabel>
      VAT number <span className="text-destructive-foreground">*</span>
    </FieldLabel>
    <FieldControl name="vat" required />
  </Field>
) : null}
```

Disabled controls are skipped by keyboard focus, dimmed, and excluded from submission, so a `required` marker on them is either a lie (the form submits fine without it) or a trap (the field cannot be filled). Render the field only when it applies, or leave it enabled with a description explaining when it matters.

## Examples [#examples]

These examples cover the Field states and relationships that are independent of a specific control. For composition with Autocomplete, Checkbox, Combobox, Number Field, Radio Group, Select, Slider, Switch, or Textarea, use that control's page.

### Required field [#required-field]

The asterisk marks the field visually; the `required` attribute enforces it and announces it.

```tsx
import {
  Field,
  FieldControl,
  FieldError,
  FieldLabel,
} from "@/components/honest-ui/ui/field"

export function FieldRequiredDemo() {
  return (
    <Field className="w-full max-w-64">
      <FieldLabel>
        Password <span className="text-destructive-foreground">*</span>
      </FieldLabel>
      <FieldControl
        type="password"
        placeholder="Enter password"
        required
      />
      <FieldError>Please fill out this field.</FieldError>
    </Field>
  )
}

```

### Disabled field [#disabled-field]

Disabling the root dims the label and description too, so the whole unit reads as inactive.

```tsx
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldLabel,
} from "@/components/honest-ui/ui/field"

export function FieldDisabledDemo() {
  return (
    <Field className="w-full max-w-64" disabled>
      <FieldLabel>Email</FieldLabel>
      <FieldControl
        type="email"
        placeholder="Enter your email"
        disabled
      />
      <FieldDescription>
        This field is currently disabled.
      </FieldDescription>
    </Field>
  )
}

```

### Native validation error [#native-validation-error]

The example starts with an incomplete address. Pressing <kbd>Enter</kbd> commits validation and shows the associated error; clicking **Validate email** triggers the browser's native message.

```tsx
import { Button } from "@/components/honest-ui/ui/button";
import {
  Field,
  FieldControl,
  FieldError,
  FieldLabel,
} from "@/components/honest-ui/ui/field";
import { Form } from "@/components/honest-ui/ui/form";

export function FieldWithErrorDemo() {
  return (
    <Form className="grid w-full max-w-64 gap-[var(--hui-space-4)]">
      <Field name="email">
        <FieldLabel>Email address</FieldLabel>
        <FieldControl
          type="email"
          defaultValue="name@"
          placeholder="name@example.com"
          required
        />
        <FieldError>
          Enter an email address in the format name@example.com.
        </FieldError>
      </Field>
      <Button type="submit">Validate email</Button>
    </Form>
  );
}

```

### Custom validity output [#custom-validity-output]

`FieldValidity` hands your render function the raw validity data, useful for debugging or bespoke messaging.

```tsx
"use client"

import {
  Field,
  FieldControl,
  FieldLabel,
  FieldValidity,
} from "@/components/honest-ui/ui/field"

export function FieldWithValidityDemo() {
  return (
    <Field className="w-full max-w-80">
      <FieldLabel>Email</FieldLabel>
      <FieldControl
        type="email"
        placeholder="Enter your email"
        required
      />
      <FieldValidity>
        {(validity) => (
          <div className="flex w-full flex-col gap-2">
            {validity.error && (
              <p className="text-xs text-destructive-foreground">
                {validity.error}
              </p>
            )}
            <div className="w-full rounded-md bg-muted p-2">
              <pre className="no-scrollbar max-h-60 overflow-y-auto font-mono text-xs">
                {JSON.stringify(validity, null, 2)}
              </pre>
            </div>
          </div>
        )}
      </FieldValidity>
    </Field>
  )
}

```

### Password instructions [#password-instructions]

A description states the rule before anyone types.

```tsx
import { Field, FieldControl, FieldDescription, FieldLabel } from "@/components/honest-ui/ui/field"

export function FieldPassword() {
  return (
    <Field className="w-full max-w-xs">
      <FieldLabel>Password</FieldLabel>
      <FieldControl type="password" placeholder="********" />
      <FieldDescription>Use at least 12 characters.</FieldDescription>
    </Field>
  )
}

```

### Complete form [#complete-form]

Fields composed with Select, Checkbox, pending-free submission, and an `aria-live` status line.

```tsx
"use client";

import * as React from "react";

import { Button } from "@/components/honest-ui/ui/button";
import { Checkbox } from "@/components/honest-ui/ui/checkbox";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/honest-ui/ui/field";
import { Form } from "@/components/honest-ui/ui/form";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/honest-ui/ui/select";

export function FieldCompleteFormDemo() {
  const [status, setStatus] = React.useState("");
  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const data = {
      fullName: formData.get("fullName"),
      email: formData.get("email"),
      role: formData.get("role"),
      newsletter: formData.get("newsletter"),
    };
    setStatus(
      `Submitted ${data.fullName} (${data.email})${
        data.role ? ` as ${data.role}` : ""
      }. Newsletter ${data.newsletter ? "enabled" : "disabled"}.`,
    );
  };
  return (
    <Form onSubmit={onSubmit} className="w-full max-w-64 grid gap-4">
      <Field>
        <FieldLabel>
          Full name <span className="text-destructive">*</span>
        </FieldLabel>
        <FieldControl
          name="fullName"
          type="text"
          placeholder="John Doe"
          required
        />
        <FieldError>Please enter a valid name.</FieldError>
      </Field>

      <Field>
        <FieldLabel>
          Email address <span className="text-destructive">*</span>
        </FieldLabel>
        <FieldControl
          name="email"
          type="email"
          placeholder="john@example.com"
          required
        />
        <FieldError>Please enter a valid email.</FieldError>
      </Field>

      <Field>
        <FieldLabel>Role</FieldLabel>
        <Select
          name="role"
          items={[
            { label: "Select your role", value: null },
            { label: "Developer", value: "developer" },
            { label: "Designer", value: "designer" },
            { label: "Product Manager", value: "manager" },
            { label: "Other", value: "other" },
          ]}
        >
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectPopup>
            <SelectItem value="developer">Developer</SelectItem>
            <SelectItem value="designer">Designer</SelectItem>
            <SelectItem value="manager">Product Manager</SelectItem>
            <SelectItem value="other">Other</SelectItem>
          </SelectPopup>
        </Select>
        <FieldDescription>This field is optional.</FieldDescription>
      </Field>

      <Field>
        <div className="flex items-center gap-2">
          <Checkbox name="newsletter" />
          <FieldLabel className="cursor-pointer">
            Subscribe to newsletter
          </FieldLabel>
        </div>
      </Field>

      <Button type="submit">Save profile</Button>
      <p className="text-sm text-muted-foreground" role="status">
        {status}
      </p>
    </Form>
  );
}

```

### Custom validate function [#custom-validate-function]

Return a string from `validate` to fail the field with your own message; return `null` to pass it.

<ComponentSource name="field-validate" title="examples/field-validate.tsx" />

## API reference [#api-reference]

All parts forward their matching Base UI Field props plus the `render` composition prop. `Field` accepts:

| Prop                     | Values                                                         | Default    |
| ------------------------ | -------------------------------------------------------------- | ---------- |
| `validationMode`         | `onSubmit`, `onBlur`, `onChange`                               | `onSubmit` |
| `validate`               | `(value, formValues) => string \| string[] \| null \| Promise` | —          |
| `validationDebounceTime` | number (ms, applies to `onChange`)                             | `0`        |
| `disabled`               | boolean; takes precedence over the control's own `disabled`    | `false`    |
| `name`                   | string; takes precedence over the control's `name`             | —          |
| `invalid`                | boolean; drives state when an external library owns validity   | —          |
| `dirty`                  | boolean; overrides the built-in changed-from-initial tracking  | —          |
| `touched`                | boolean; overrides the built-in touch tracking                 | —          |

`FieldControl` adds Honest UI's `size`: `"sm"` (28 px), `"default"` (32 px), `"lg"` (40 px), or a number of characters. `FieldError` accepts `match`: pass `true` to always render its children, or a `ValidityState` key such as `"typeMismatch"` to gate visibility. `FieldHelperSlot`, `FieldGroup`, and `FieldSeparator` accept native `div` props; only the group and separator change layout, none of them add semantics.

See the [Base UI Field API](https://base-ui.com/react/components/field#api-reference) for validation internals and control composition.
