Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Field

A field gives a form control its label, description and error message, and links them with ids and ARIA, so a screen reader reads the label, the description, the error and the required and invalid states when the control gets focus. In a Form, it also binds the control’s value to the form by its name. Field renders no control: its control part goes on yours.

Every UniAria form component already has Field inside, with its own label, description and error parts: a TextField, a RadioGroup or a Combobox needs no Field around it (see In UniAria components). Use Field itself for a control UniAria doesn’t have:

  • A native control UniAria has no component for: a <select>, or a date, color or file <input>.
  • A third-party widget, such as a date picker or a rich text editor (see A control that isn’t native).
  • A control of your own. Compose Field into its behavior, and it gets the labelling and the form binding (see A field of your own).

Field shows validation state. In a form, the form checks the field and gives Field its errors; without one, your code decides when a field is invalid and passes isInvalid. See Forms for the whole picture.

There is no WAI-ARIA pattern for a field. Field follows the APG practice on names and descriptions.

Order form
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The fields' look is @uniaria/styles' default. */
:root {
  font:
    15px/1.5 system-ui,
    sans-serif;
}
body {
  display: grid;
  gap: 1rem;
  margin: 1.5rem;
  background: var(--uni-surface-color);
  color: var(--uni-text-color);
}
form {
  display: grid;
  gap: 1rem;
  justify-items: start;
  max-inline-size: 20rem;
}
form > uni-field {
  justify-self: stretch;
}
</style><form id="order" novalidate>
  <uni-field id="country-field" required>
    <div data-uni="field">
      <label data-element="Label"
        >Country <span aria-hidden="true">*</span></label
      >
      <select data-element="Control" id="country" name="country">
        <option value="">Choose a country</option>
        <option value="de">Germany</option>
        <option value="fr">France</option>
        <option value="jp">Japan</option>
      </select>
      <p data-element="Description">We ship your order there.</p>
      <p data-element="Error" hidden>Choose a country.</p>
    </div>
  </uni-field>

  <uni-checkbox id="terms" required>
    <div data-uni="checkbox">
      <label data-element="Wrapper">
        <input data-element="Input" type="checkbox" name="terms" />
        <span data-element="Indicator"></span>
        <span data-element="Label">I accept the terms</span>
      </label>
      <p data-element="Error" hidden>Accept the terms to continue.</p>
    </div>
  </uni-checkbox>

  <uni-button type="submit">
    <button data-uni="button" data-variant="primary">Order</button>
  </uni-button>
</form>
<output id="log">Submit the form empty to see the errors.</output>
<script src="/demo/field.js"></script><script>// Field shows validation state; the form decides it. This form checks on
// submit, then again on every change.
const form = document.getElementById('order');
const countryField = document.getElementById('country-field');
const country = document.getElementById('country');
const terms = document.getElementById('terms');
const log = document.getElementById('log');
let wasSubmitted = false;

function validate() {
  countryField.toggleAttribute('invalid', country.value === '');
  terms.toggleAttribute('invalid', !form.elements.terms.checked);
  return (
    !countryField.hasAttribute('invalid') && !terms.hasAttribute('invalid')
  );
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  wasSubmitted = true;
  log.textContent = validate()
    ? `Ordered for ${country.selectedOptions[0].textContent}.`
    : 'Fix the marked fields.';
});
const revalidate = () => {
  if (wasSubmitted) validate();
};
country.addEventListener('change', revalidate);
terms.addEventListener('selectedchange', revalidate);
</script>

Usage

React

import { field } from '@uniaria/core/field';
import { useBehavior } from '@uniaria/react';

export function CountryField(props: { error?: string }) {
  const country = useBehavior(field, {
    name: 'country',
    isRequired: true,
    isInvalid: props.error !== undefined,
  });
  return (
    <div {...country.parts.root}>
      <label {...country.parts.label}>
        Country <span aria-hidden="true">*</span>
      </label>
      <select {...country.parts.control}>
        <option value="">Choose a country</option>
        <option value="de">Germany</option>
        <option value="fr">France</option>
      </select>
      <p {...country.parts.description}>We ship your order there.</p>
      <p {...country.parts.error}>{props.error}</p>
    </div>
  );
}

Vue

<script setup lang="ts">
import { field } from '@uniaria/core/field';
import { useBehavior } from '@uniaria/vue';

const props = defineProps<{ error?: string }>();

const country = useBehavior(field, () => ({
  name: 'country',
  isRequired: true,
  isInvalid: props.error !== undefined,
}));
</script>

<template>
  <div v-bind="country.parts.root">
    <label v-bind="country.parts.label">
      Country <span aria-hidden="true">*</span>
    </label>
    <select v-bind="country.parts.control">
      <option value="">Choose a country</option>
      <option value="de">Germany</option>
      <option value="fr">France</option>
    </select>
    <p v-bind="country.parts.description">We ship your order there.</p>
    <p v-bind="country.parts.error">{{ error }}</p>
  </div>
</template>

HTML

<uni-field id="country-field" required>
  <label data-element="Label">Country <span aria-hidden="true">*</span></label>
  <select data-element="Control" id="country" name="country">
    <option value="">Choose a country</option>
    <option value="de">Germany</option>
    <option value="fr">France</option>
  </select>
  <p data-element="Description">We ship your order there.</p>
  <p data-element="Error" hidden>Choose a country.</p>
</uni-field>

<script type="module">
  import '@uniaria/elements/field';

  const country = document.querySelector('#country');
  country.addEventListener('change', () => {
    document
      .querySelector('#country-field')
      .toggleAttribute('invalid', country.value === '');
  });
</script>

parts.control holds what Field writes on your control: its id (unless it has one), aria-labelledby, aria-describedby, aria-invalid, aria-required and name, and listeners that follow its value and focus. The rest of the control is yours: its options, type, multiple, disabled.

Give the name to Field, not to the control. Field writes it on the control, so a native form post sends it. In React and Vue, Field doesn’t read the control’s attributes: a name written only on the <select> leaves the field without one, and in a Form it doesn’t bind. The custom element is the exception: <uni-field> reads the name from its control’s name attribute.

Render the error part every time: Field hides it with hidden while the field is valid.

Showing errors

When isInvalid turns on, Field shows the error, links it to the control and announces its text, as role="alert" would. While the error stays shown, new text in it is announced too, so one error part can say “Enter your email” and then “Enter a valid email”. The timing is yours: validate on submit, on blur, or as the user types.

An error already shown when the field starts isn’t announced: the screen reader reads it when the control gets focus. Nothing is announced while the field is disabled, or for an error part that is missing or empty.

In a form

In a Form, leave isInvalid unset: the form checks the field (isRequired, the native constraints on the control, then validate) and gives Field its errors. Field follows the control’s value, and an errorText part inside the error shows the first error:

const country = useBehavior(field, { name: 'country', isRequired: true });

<p {...country.parts.error}>
  <span {...country.parts.errorText} />
</p>;

The field binds to the form around it (through OwnerProvider in React), or to the one in its form prop. While the form controls the value, Field writes it back to the control, a <select multiple>’s options included. See Field props for the form.

A control that isn’t native

A widget that isn’t a native control (a date picker from another library, a rich text editor) needs two things from you:

  • The label as another element. Only a native control can be labelled by a <label>. Render the label as a <span> and pass its tag as labelElementType. The label then gets no for, and names the control through aria-labelledby only. A click on it doesn’t focus the control.
  • The value. Field can’t read a widget’s value. Report each change with Field’s setValue action, and render the value from Field’s state: the form’s while bound.

React

import { field } from '@uniaria/core/field';
import { useBehavior, useBehaviorState } from '@uniaria/react';

export function DueDateField() {
  const due = useBehavior(field, {
    name: 'due',
    isRequired: true,
    labelElementType: 'span',
  });
  const value = useBehaviorState(due, (state) => state.value as Date | null);
  return (
    <div {...due.parts.root}>
      <span {...due.parts.label}>Due date</span>
      {/* It must pass the props to its focusable element. */}
      <DatePicker
        {...due.parts.control}
        value={value}
        onChange={(date) => due.actions.setValue(date)}
      />
      <p {...due.parts.error}>
        <span {...due.parts.errorText} />
      </p>
    </div>
  );
}

Vue

<script setup lang="ts">
import { field } from '@uniaria/core/field';
import { useBehavior, useBehaviorState } from '@uniaria/vue';

const due = useBehavior(field, {
  name: 'due',
  isRequired: true,
  labelElementType: 'span',
});
const value = useBehaviorState(due, (state) => state.value as Date | null);
</script>

<template>
  <div v-bind="due.parts.root">
    <span v-bind="due.parts.label">Due date</span>
    <!-- It must pass the attributes to its focusable element. -->
    <DatePicker
      v-bind="due.parts.control"
      :model-value="value"
      @update:model-value="(date) => due.actions.setValue(date)"
    />
    <p v-bind="due.parts.error">
      <span v-bind="due.parts.errorText" />
    </p>
  </div>
</template>

HTML

<uni-field id="due-field" required>
  <span data-element="Label">Due date</span>
  <date-picker data-element="Control"></date-picker>
  <p data-element="Error"><span data-element="ErrorText"></span></p>
</uni-field>

<script type="module">
  import '@uniaria/elements/field';

  const due = document.querySelector('#due-field');
  due.querySelector('date-picker').addEventListener('change', (event) => {
    due.setValue(event.target.value);
  });
</script>

The element reads the label’s tag, so there is no attribute for it. Its setValue() method is the action, and its value property the value.

For radios and checkboxes, use RadioGroup and CheckboxGroup, which have Field built in.

Extra labels and descriptions

labelledBy and describedBy add ids of other elements after Field’s own, for a hint that sits elsewhere on the page or a unit next to the control. Don’t write aria-labelledby or aria-describedby on the control yourself: Field owns them and replaces them.

<p id="password-rules">At least 12 characters.</p>
<uni-field described-by="password-rules">…</uni-field>

In UniAria components

Every UniAria form component has Field built in: its label, description and error are parts of the component itself, with the same props, the same linking, the same error announcements and the same form binding. Render them on the component, with no Field around it:

const plan = useBehavior(radioGroup, { name: 'plan', isRequired: true });

<div {...plan.parts.root}>
  <span {...plan.parts.label}>Plan</span>
  <OwnerProvider owner={plan}>{/* the radios */}</OwnerProvider>
  <p {...plan.parts.description}>You can change it later.</p>
  <p {...plan.parts.error}>
    <span {...plan.parts.errorText} />
  </p>
</div>;

const fruit = useBehavior(combobox, { items: fruits, name: 'fruit' });

<div {...fruit.parts.root}>
  <label {...fruit.parts.label}>Fruit</label>
  <div {...fruit.parts.anchor}>{/* the input and its button */}</div>
  <p {...fruit.parts.description}>We deliver it fresh.</p>
  <p {...fruit.parts.error}>
    <span {...fruit.parts.errorText} />
  </p>
  {/* the popover with the list */}
</div>;

Field’s control is the element the component’s label names:

Component Field’s control Label element by default Notes
TextField input label An <input> or a <textarea>.
NumberField input label The label also names the stepper buttons.
Checkbox input label Inside a <label> wrapper or root, the label part is a <span>. In a group, each checkbox also links the group’s error while shown.
Switch input label As Checkbox.
RadioGroup root span The radiogroup. Each radio links its own label and description, and the group’s error while shown.
CheckboxGroup root span The group.
Listbox list span
Select trigger span
Combobox input label

The component’s markers and state attributes go on those parts, so style them through the component ([data-uni='text-field']), not through field. Where you can name the control’s id with a prop, the prop is named after the part: inputId, triggerId, listId. An id you write on the part itself wins.

Use Field itself only for native and third-party controls. Don’t wrap a UniAria component in <uni-field>: the field doesn’t look inside another UniAria element, so its label would link to nothing, and in development it warns. To build your own component on Field, see A field of your own.

Anatomy

Part Element Notes
root any; usually div Optional. The outermost element, around the other parts, for styling. No role.
label label by default; any element through labelElementType You own the content. UniAria writes its id (unless it has one), and for when it is a label.
control any control: input, textarea, select, or a custom widget UniAria writes id, aria-labelledby, aria-describedby, aria-invalid and aria-required; name from the name prop, and form when it sits outside its form’s <form>; a native control’s value while a form or value controls it. You own the rest: type, disabled, readonly, a custom widget’s role.
description any UniAria writes its id (unless it has one).
error any UniAria writes its id (unless it has one), and hidden while the error isn’t shown. You own the content.
errorText any, inside error Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself.

Every part is optional. Every part keeps an id (unless it has one) you give it, and the links below use it; UniAria writes an id (unless it has one) only on a part that has none. Write it on the element (after the part spread in React, after v-bind in Vue), or name the control’s with controlId. Without either, UniAria generates one (see Ids).

The parts link like this:

  • Label. The label gets an id (unless it has one), and for pointing at the control when it is a <label>, so a click on it focuses the control and a native control is named before JavaScript runs. The control gets aria-labelledby with the label’s id, then labelledBy. While the label is rendered, it names the control, so an aria-label on the control is ignored.
  • Description and error. The control’s aria-describedby lists the description, then the error while it is shown, then describedBy. A hidden error is left out, since screen readers read hidden content a reference points at.
  • Only rendered parts. An id goes into a reference only once its part is rendered, so no reference points at nothing. In React that happens right after the first commit, in Vue right after mount, both before paint: server HTML has every id and the label’s for, and gains the references on hydration. The custom element links on upgrade, and again whenever a part is added or removed.

Props

Prop Attribute Type Default Description
isInvalid invalid boolean — aria-invalid="true" on the control, removed when false (never "false"). Shows, links and announces the error. Unset in a Form, the form’s errors decide it.
isRequired required boolean false aria-required="true" on the control. Never the native required, which would start the browser’s own validation.
isDisabled disabled boolean false data-disabled on every part, and hides the error. It doesn’t disable the control: disable a native control yourself.
controlId — string generated The control’s id while the control has none of its own. Server HTML from React or Vue has it, so the label’s for is right before hydration.
labelElementType — (read from the label’s tag) string 'label' The label’s tag. for goes on a label only.
labelledBy labelled-by string — More ids for the control’s aria-labelledby, space-separated, after the label’s.
describedBy described-by string — More ids for the control’s aria-describedby, after the description and the error: a hint elsewhere on the page, for example.
value — (property only) unknown — Bindable: what a native control holds, or what setValue gave a custom one. Seeded by defaultValue, reported as valueChange. Ignored while bound to a form.

Field also takes the props every field takes for a form: name, written on the control even outside a form, form, validate, validationTrigger, validationDelay, validationMessages, dependsOn, isEqual and shouldPreserve. See Field props for the form.

Field reports valueChange for the user’s changes and handles no keys: the control keeps its own.

State

Field Type Meaning
isInvalid boolean The isInvalid prop, else in a form whether it holds errors.
isRequired boolean The isRequired prop.
isDisabled boolean The isDisabled prop, or the form’s.
isErrorShown boolean The error is shown: invalid and not disabled.
value unknown The value: the control’s, or the form’s under its name while bound.
errors unknown[] The form’s errors for it; [] outside a form.
status 'unvalidated' | 'validating' | 'valid' | 'invalid' Its last check in the form.
isTouched boolean The user changed or left it, in a form.
isDirty boolean Its value differs from its default, in a form.
isBound boolean It is bound to a form.

Field has one action, setValue(value): a user’s change from a custom control, which a form takes as typing in a native one. Around a native input, select or textarea, Field follows the control’s value itself.

You rarely need it: the state attributes below cover styling. In React or Vue, select a value with useBehaviorState (Vue gives a ref), and the component renders again only when it changes:

const isErrorShown = useBehaviorState(email, (state) => state.isErrorShown);

On the custom element, read element.instance.getState(). See State.

Styling

Attribute On When
data-uni="field" root Always.
data-element every other part Always.
data-invalid every part isInvalid is set.
data-required every part isRequired is set.
data-disabled every part isDisabled is set.
hidden error The error isn’t shown.

The state attributes go on every part, so you can style the label and the messages from the field’s state without reading it in JavaScript. A required mark, an “(optional)” note and a success state are yours to draw.

Default styles

The optional @uniaria/styles package styles the label, the description and the error, as in the demo above, whose CSS tab holds only the page’s layout. A native text control (an input you type in, a textarea or a select) becomes the input box, with the focus ring and the invalid border; any other control, such as a group, keeps its own look.

Accessibility

  • Spoken output. For a label “Email”, a description “We never share it” and an error “Enter a valid email”, a screen reader reads the control as “textbox, Email, We never share it, required” while valid, and “textbox, Email, We never share it Enter a valid email, invalid, required” while invalid. A checkbox control reads “checkbox, Accept terms, You must accept, not checked, invalid, required”.
  • The required mark. Hide a * in the label with aria-hidden="true": the control already announces “required”.
  • Announcements. Field announces a new error through UniAria’s live announcer, a visually hidden region it adds to the page. Don’t add role="alert" or aria-live to the error part, or it is read twice.
  • Disabled. isDisabled hides the error, since a disabled control can’t be fixed. A native control you disable reads “disabled, invalid” without the error text.
  • Groups. A role="group" control announces neither invalid nor required; ARIA supports both on radiogroup but not on group. There, the error text in aria-describedby is all a screen reader user hears.
  • Owned attributes. Pass more ids through labelledBy and describedBy instead of writing aria-labelledby or aria-describedby. Ids are yours to set on any part. React and Vue warn in development when you pass an attribute UniAria writes.

Compared with other libraries

  • React Aria links errors the same way, through aria-describedby, but announces nothing when one appears. Field announces it, as the role="alert" messages of many design systems do. Lion makes the error a live region instead.
  • Validation is the form’s. In a Form, a field checks isRequired, the native constraints on its control and validate, as React Aria’s validate and native validation do. Outside one, pass isInvalid from your form library or your own checks.
  • No aria-errormessage. VoiceOver and NVDA don’t read it, so the error goes in aria-describedby, as React Aria, Lion and Spectrum Web Components do. The APG’s spin button example uses aria-errormessage.
  • No native required. Field writes aria-required only, so the browser shows no validation bubbles of its own.