Docs
Next

Form fields

Labelled fields with descriptions, errors and widths: TextField, EmailField, PhoneField, PasswordField and seven more.

On this page

A field component renders a whole form row in one element: a FormGroup with a label, an optional description, the control and an error message, with the ARIA attributes between them already wired. There is one field per control type. Start with these; drop to the primitives only for layouts they don't cover.

Fields

We never share it.

At least 12 characters.

import { EmailField, Form, PasswordField, TextField } from '@systhemaui/next'

export default function Demo() {
  return (
    <Form style={{ width: '100%', maxWidth: 480 }}>
      <TextField name="fields-name" label="Full name" autoComplete="name" required />
      <EmailField name="fields-email" label="Email" description="We never share it." required />
      <PasswordField
        name="fields-password"
        label="Password"
        description="At least 12 characters."
        descriptionPosition="bottom"
        autoComplete="new-password"
        minLength={12}
        required
      />
    </Form>
  )
}

ImportLink to this section

import { EmailField, TextField } from '@systhemaui/next'

In a React app without Next.js, import them from @systhemaui/react.

Shared propsLink to this section

Every field takes these props on top of its control's own attributes:

PropWhat it does
nameRequired. Also the default id, which the label's htmlFor points at
labelThe label. Any React node on the single-control fields; a string on RadioField and CheckboxField
descriptionHelp text in a FormDescription
descriptionPosition'top' (default) puts the description between label and control, 'bottom' below the control
invalidMessageThe error text, in a FormInvalidMessage
widthA CSS percentage that sizes the field inside a FormRow from md up (see Widths)
slotPropsProps for the surrounding elements: group, label, description, invalidMessage (and inputGroup on the group fields)

Everything else (required, placeholder, defaultValue, onChange, className, …) goes to the control itself.

ExamplesLink to this section

Description positionLink to this section

Description above and below

Shown on your profile.

Shown on your profile.

import { FormRow, TextField } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormRow style={{ width: '100%', maxWidth: 640 }}>
      <TextField name="position-top" label="Username" description="Shown on your profile." />
      <TextField
        name="position-bottom"
        label="Display name"
        description="Shown on your profile."
        descriptionPosition="bottom"
      />
    </FormRow>
  )
}

Showing an errorLink to this section

The error message stays hidden until the browser marks the control :user-invalid, which happens once the visitor has changed the value or tried to submit the form. So with native validation (required, pattern, minLength, the input type), pass invalidMessage up front and the message appears at the right moment. Type something that isn't an email address below and leave the field:

Native validation
import { EmailField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <EmailField
        name="error-native"
        label="Email"
        required
        invalidMessage="Enter an email address like jane@example.com."
      />
    </div>
  )
}

When you validate in JavaScript (or on the server), add the is-invalid class to the control: it forces the invalid style and shows the message immediately.

Server-side error
import { TextField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <TextField
        name="error-server"
        label="Voucher code"
        defaultValue="SUMMER2019"
        className="is-invalid"
        invalidMessage="This code expired on 31 August 2019."
      />
    </div>
  )
}

WidthsLink to this section

Widths in a row
import { EmailField, FormRow, PhoneField, TextField } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormRow style={{ width: '100%', maxWidth: 640 }}>
      <TextField name="widths-first" label="First name" width="50%" />
      <TextField name="widths-last" label="Last name" width="50%" />
      <EmailField name="widths-email" label="Email" width="67%" />
      <PhoneField name="widths-phone" label="Phone" width="33%" />
    </FormRow>
  )
}

Slot propsLink to this section

slotProps reaches the elements around the control without dropping to primitives, for example to give the group an id or restyle the description. The control's own props stay at the top level:

;<TextField
  name="company"
  label="Company"
  description="Optional"
  autoComplete="organization"
  slotProps={{
    group: { id: 'company-group' },
    description: { className: 'italic' },
  }}
/>

FieldsLink to this section

TextFieldLink to this section

A text input. type defaults to 'text'; override it for other text-like inputs such as url or search. For email, phone, number, date and password, use the dedicated field, which sets the right inputMode and autoComplete.

TextField

Include https://.

import { TextField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <TextField
        name="field-website"
        type="url"
        label="Website"
        placeholder="https://"
        description="Include https://."
        invalidMessage="Enter a full URL."
      />
    </div>
  )
}

EmailFieldLink to this section

An type="email" input with inputMode="email", autoComplete="email" and a case-insensitive pattern that requires a domain with a top-level part (jane@example fails, Jane.Doe+news@Example.com passes). Each default can be overridden.

EmailField
import { EmailField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <EmailField
        name="field-email"
        label="Work email"
        defaultValue="jane@example"
        className="is-invalid"
        invalidMessage="Add the domain ending, for example .com."
      />
    </div>
  )
}

PhoneFieldLink to this section

A type="tel" input with inputMode="tel" and autoComplete="tel". It adds no pattern, because phone formats vary; pass pattern if you need one.

PhoneField

Only used for delivery updates.

import { PhoneField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <PhoneField
        name="field-phone"
        label="Phone"
        placeholder="+32 470 12 34 56"
        description="Only used for delivery updates."
      />
    </div>
  )
}

PasswordFieldLink to this section

A type="password" input. Set autoComplete to current-password on sign-in forms and new-password on sign-up and reset forms, so password managers act correctly.

PasswordField

At least 12 characters.

import { PasswordField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <PasswordField
        name="field-password"
        label="New password"
        autoComplete="new-password"
        minLength={12}
        required
        description="At least 12 characters."
        descriptionPosition="bottom"
        invalidMessage="Use at least 12 characters."
      />
    </div>
  )
}

NumberFieldLink to this section

A type="number" input with inputMode="decimal". Use min, max and step for the range.

NumberField

Up to 12 per booking.

import { NumberField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <NumberField
        name="field-guests"
        label="Guests"
        min={1}
        max={12}
        defaultValue={2}
        description="Up to 12 per booking."
        invalidMessage="Choose between 1 and 12 guests."
      />
    </div>
  )
}

DateFieldLink to this section

A date input. inputType picks 'date' (default) or 'datetime-local'; min and max take ISO strings.

DateField
import { DateField, FormRow } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormRow style={{ width: '100%', maxWidth: 640 }}>
      <DateField name="field-arrival" label="Arrival" min="2026-01-01" />
      <DateField name="field-meeting" label="Call" inputType="datetime-local" />
    </FormRow>
  )
}

TextareaFieldLink to this section

A multi-line text area. The default CSS gives it a fixed height of 328 px with resizing off; resize lets the visitor resize it vertically, and a style or class sets another height.

TextareaField

Tell us about your project.

import { TextareaField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <TextareaField
        name="field-message"
        label="Message"
        description="Tell us about your project."
        maxLength={2000}
        resize
        style={{ height: 160 }}
      />
    </div>
  )
}

SelectFieldLink to this section

A FormSelect with a label and messages. It takes the same Option and SelectPlaceholder children and the same selectClassName, iconClassName, prepend, append and disableIcon props. className goes on the select wrapper.

SelectField

Routes your message to the right team.

import { Option, SelectField, SelectPlaceholder } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <SelectField
        name="field-topic"
        label="Topic"
        required
        description="Routes your message to the right team."
        invalidMessage="Choose a topic."
      >
        <SelectPlaceholder>Choose a topic</SelectPlaceholder>
        <Option value="sales">Sales</Option>
        <Option value="support">Support</Option>
        <Option value="press">Press</Option>
      </SelectField>
    </div>
  )
}

FileFieldLink to this section

A FormFileInput with a label and messages; accept and multiple pass through.

FileField

Images or PDF, up to 10 MB each.

import { FileField } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <FileField
        name="field-attachments"
        label="Attachments"
        accept="image/*,.pdf"
        multiple
        description="Images or PDF, up to 10 MB each."
      />
    </div>
  )
}

RadioField and CheckboxFieldLink to this section

The group fields take their choices as items, each an object with the FormRadio or FormCheckbox props minus name. The group label is a <div> that names the group through aria-labelledby, and the description and error attach to the group. required is applied to every radio, so one choice is needed.

RadioField
Delivery

Pick-up is free.

import { RadioField } from '@systhemaui/next'

export default function Demo() {
  return (
    <RadioField
      name="field-delivery"
      label="Delivery"
      description="Pick-up is free."
      required
      items={[
        {
          id: 'delivery-standard',
          value: 'standard',
          label: 'Standard (3 to 5 days)',
          defaultChecked: true,
        },
        { id: 'delivery-express', value: 'express', label: 'Express (next day)' },
        { id: 'delivery-pickup', value: 'pickup', label: 'Pick up in store' },
      ]}
    />
  )
}

Each item's id defaults to its value. Two groups on one page with the same values (yes / no) would then share ids, so give the items ids, as above.

For checkboxes, requiredMode decides what required means: 'each' (default) puts required on every box, for a list of things that must all be confirmed; 'any' asks for at least one box. In 'any' mode the field reads the items' checked props, so control the items with state:

CheckboxField with requiredMode any
Topics

Choose at least one.

'use client'

import { useState } from 'react'
import { CheckboxField } from '@systhemaui/next'

const topics = [
  { value: 'design', label: 'Design' },
  { value: 'development', label: 'Development' },
  { value: 'content', label: 'Content' },
]

export default function Demo() {
  const [selected, setSelected] = useState<string[]>(['design'])

  return (
    <CheckboxField
      name="field-topics"
      label="Topics"
      description="Choose at least one."
      required
      requiredMode="any"
      invalidMessage="Choose at least one topic."
      items={topics.map((topic) => ({
        ...topic,
        id: `topics-${topic.value}`,
        checked: selected.includes(topic.value),
        onChange: () =>
          setSelected((current) =>
            current.includes(topic.value)
              ? current.filter((value) => value !== topic.value)
              : [...current, topic.value],
          ),
      }))}
    />
  )
}

PropsLink to this section

TextFieldLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
autoCompletestring-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
typeHTMLInputTypeAttribute'text'Defaults to 'text'. Override for niche text-style inputs (e.g. 'search', 'url'). For email, tel, number, date, password, prefer the dedicated EmailField / PhoneField / NumberField / DateField / PasswordField — those carry sensible default inputMode, autoComplete, and (for email) pattern.
widthstring-
slotPropsFieldSlotProps-
…and all <input> attributes

EmailFieldLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
autoCompletestring-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

PhoneFieldLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
autoCompletestring-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

PasswordFieldLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
autoCompletestring-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

NumberFieldLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
autoCompletestring-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

DateFieldLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
autoCompletestring-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
inputType'date' | 'datetime-local''date'
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

TextareaFieldLink to this section

PropTypeDefaultDescription
name (required)string-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
idstring-
resizebooleanfalse
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

SelectFieldLink to this section

PropTypeDefaultDescription
children (required)ReactNode-
name (required)string-
prependReactNode-
appendReactNode-
selectClassNamestring-
iconClassNamestring-
disableIconboolean-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
idstring-
classNamestring-
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

FileFieldLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
acceptstring-
multipleboolean-
labelReactNode-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
widthstring-
slotPropsFieldSlotProps-
…and all inherited HTML attributes

RadioFieldLink to this section

PropTypeDefaultDescription
name (required)string-
items (required)Omit<FormRadioProps, 'name'>[]-
labelstring-
idstring-
requiredboolean-
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
widthstring-
slotPropsInlineFieldSlotProps-

CheckboxFieldLink to this section

PropTypeDefaultDescription
name (required)string-
items (required)Omit<FormCheckboxProps, 'name'>[]-
labelstring-
idstring-
requiredboolean-
requiredMode'each' | 'any'"each"How required validation works for checkbox groups: - "each": every checkbox must be checked (native HTML required on each input) - "any": at least one checkbox must be checked (uses a hidden proxy input for native validation)
descriptionReactNode-
descriptionPosition'top' | 'bottom'-
invalidMessageReactNode-
widthstring-
slotPropsInlineFieldSlotProps-

FieldSlotPropsLink to this section

PropTypeDefaultDescription
groupOmit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'>-
labelOmit<Omit<DetailedHTMLProps<LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>, 'ref'>, 'htmlFor'>-
descriptionOmit<DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>, 'ref'>-
invalidMessageOmit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'>-

InlineFieldSlotPropsLink to this section

PropTypeDefaultDescription
groupOmit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'>-
labelOmit<Omit<DetailedHTMLProps<LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>, 'ref'>, 'htmlFor'>-
descriptionOmit<DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>, 'ref'>-
invalidMessageOmit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'>-
inputGroupOmit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'>-

HTML and CSSLink to this section

A field renders this markup. Write it by hand in an HTML project; the ids and aria-* attributes are what the component adds for you.

<div class="form-group" style="--width: 50%">
  <label class="form-label" for="email">Email</label>
  <p class="form-description" id="email-description">We never share it.</p>
  <input
    class="form-input"
    id="email"
    name="email"
    type="email"
    inputmode="email"
    autocomplete="email"
    required
    aria-describedby="email-description email-error"
  />
  <div class="form-invalid-message" id="email-error" role="alert">Enter a valid email.</div>
</div>

<div class="form-group">
  <div class="form-label" id="delivery-label">Delivery</div>
  <div class="form-inline-input-group" role="radiogroup" aria-labelledby="delivery-label">
    <div class="form-radio">
      <input type="radio" id="standard" name="delivery" value="standard" required />
      <label for="standard">Standard</label>
    </div>
    <div class="form-radio">
      <input type="radio" id="express" name="delivery" value="express" required />
      <label for="express">Express</label>
    </div>
  </div>
</div>
ClassStyles
form& > :not([hidden], input[type="hidden"]) ~ :not([hidden], input[type="hidden"]) { margin-top: var(--form-row-gap-y); }
form-row@media (width < 640px) { & { display: flex; flex-direction: column; row-gap: var(--form-row-gap-y); } }@media (width >= 640px) { & { display: flex; flex-wrap: wrap; gap: var(--form-row-gap-x); } }@media (width >= 640px) { & > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { & > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } }
form-group@media (width >= 640px) { .form-row > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { .form-row > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } }& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-group-gap-y); }&:has(:required) .form-label::after { content: ' *'; color: var(--color-form-label-required); }+ 1 more rule
form-label& { display: block; color: var(--color-form-label); }& { font-family: var(--font-form-label-font-family); font-size: var(--typography-form-label-font-size); font-weight: var(--font-form-label-font-weight); letter-spacing: var(--typography-form-label-letter-spacing); line-height: var(--typography-form-label-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-label-font-style); }+ 1 more rule
form-description& { display: block; color: var(--color-form-description); }& { font-family: var(--font-form-description-font-family); font-size: var(--typography-form-description-font-size); font-weight: var(--font-form-description-font-weight); letter-spacing: var(--typography-form-description-letter-spacing); line-height: var(--typography-form-description-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-description-font-style); }
form-input& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 4 more rules
form-select& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 12 more rules
form-textarea& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 5 more rules
search-field-input& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 9 more rules
is-invalid:is(.form-select:user-invalid ~ .form-select-icon, .form-select.is-invalid ~ .form-select-icon) { color: var(--color-form-input-block-invalid-icon); background-color: var(--color-form-input-block-invalid-icon); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; }
form-select-wrapperposition: relative;
form-select-icon:is(.form-select ~ .form-select-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 3 more rules
search-fieldposition: relative;
search-field-icon:is(.search-field-input ~ .search-field-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 2 more rules
form-inline-input-group& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-input-inline-space-y); }
form-radio& { position: relative; user-select: none; }& input[type="radio"], .form-radio input[type="checkbox"], .form-radio:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-radio input[type="checkbox"] { opacity: 0; cursor: pointer; }&:has(:checked):after { opacity: 1; }+ 13 more rules
form-checkbox& { position: relative; user-select: none; }& input[type="radio"], .form-checkbox input[type="checkbox"], .form-checkbox:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-checkbox input[type="checkbox"] { opacity: 0; cursor: pointer; }+ 14 more rules
form-invalid-message& { display: none; color: var(--color-form-invalid-message); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; }
form-file-input& { cursor: pointer; padding: 0; height: calc(var(--form-input-block-padding-y) * 2 + var(--typography-form-block-line-height)); display: flex; align-items: center; }+ 2 more rules

Next.jsLink to this section

@systhemaui/next re-exports the field components from @systhemaui/react unchanged. They render on the server; only a module that passes event handlers (like the requiredMode="any" example) needs 'use client'.

AccessibilityLink to this section

Every field wires its parts through FormFieldShell, the shared wrapper behind all eleven fields:

  • The label is a <label for> pointing at the control's id (id defaults to name).
  • The description gets the id <id>-description and the error <id>-error. Both ids are added to the control's aria-describedby, after any aria-describedby you pass yourself.
  • When invalidMessage is set, the control gets aria-invalid="true", and the message is a role="alert" so it is announced when it appears.
  • RadioField renders its items in a role="radiogroup" and CheckboxField in a role="group", both named by the group label through aria-labelledby (<name>-label). For these, aria-describedby and aria-invalid go on the group element.
  • The required asterisk after the label is decorative CSS; assistive technology reads the required attribute.

See Accessibility for what the components guarantee across the system.