Docs

This page isn't translated yet

Form controls

FormLabel, FormInput, FormTextarea, FormFileInput, FormDescription and FormInvalidMessage, the primitives behind every field.

On this page

The primitive form controls render one element each, styled with the form tokens. Use them for layouts the pre-composed field components don't cover, such as a control with extra markup between the label and the input. For everything else, the fields are shorter and wire the ARIA attributes for you.

Label, input and description

As it appears on your invoices.

import { FormDescription, FormGroup, FormInput, FormLabel } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormGroup style={{ width: '100%', maxWidth: 480 }}>
      <FormLabel htmlFor="controls-company">Company</FormLabel>
      <FormDescription id="controls-company-description">
        As it appears on your invoices.
      </FormDescription>
      <FormInput
        type="text"
        name="controls-company"
        autoComplete="organization"
        aria-describedby="controls-company-description"
      />
    </FormGroup>
  )
}

ImportLink to this section

import {
  FormDescription,
  FormFileInput,
  FormInput,
  FormInvalidMessage,
  FormLabel,
  FormTextarea,
} from '@systhemaui/next'

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

ControlsLink to this section

FormLabelLink to this section

Renders a <label class="form-label"> when you pass htmlFor, and a <div class="form-label"> otherwise. Use the <div> form for a group label, and point the group at it with aria-labelledby.

FormInputLink to this section

A single-line <input class="form-input">. name and type are required, id defaults to name, and autoComplete defaults to 'off': set a real token (email, given-name, postal-code, …) on fields a browser can fill.

Input states
import { FormGroup, FormInput, FormLabel } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ display: 'grid', gap: 24, width: '100%', maxWidth: 480 }}>
      <FormGroup>
        <FormLabel htmlFor="state-default">Default</FormLabel>
        <FormInput type="text" name="state-default" placeholder="Placeholder text" />
      </FormGroup>
      <FormGroup>
        <FormLabel htmlFor="state-invalid">Invalid</FormLabel>
        <FormInput
          type="email"
          name="state-invalid"
          defaultValue="jane@"
          className="is-invalid"
          aria-invalid
        />
      </FormGroup>
      <FormGroup>
        <FormLabel htmlFor="state-disabled">Disabled</FormLabel>
        <FormInput type="text" name="state-disabled" defaultValue="Read only" disabled />
      </FormGroup>
    </div>
  )
}

A control takes the focus colors on :focus and the invalid colors on :user-invalid (after the visitor has interacted with it) or when it carries the is-invalid class. A disabled control drops to the disabled opacity and ignores the pointer.

FormTextareaLink to this section

A <textarea class="form-textarea"> with the same name and id rules as FormInput. With the default CSS it has a fixed height of 328 px and cannot be resized; set a height with a class or style, or use TextareaField with resize.

Textarea
import { FormGroup, FormLabel, FormTextarea } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormGroup style={{ width: '100%', maxWidth: 480 }}>
      <FormLabel htmlFor="controls-notes">Notes</FormLabel>
      <FormTextarea
        name="controls-notes"
        placeholder="Anything we should know?"
        style={{ height: 140 }}
      />
    </FormGroup>
  )
}

FormFileInputLink to this section

An <input type="file" class="form-input form-file-input">. accept and multiple pass through, and the browser's file button is restyled to match the input surface.

File input
import { FormGroup, FormFileInput, FormLabel } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormGroup style={{ width: '100%', maxWidth: 480 }}>
      <FormLabel htmlFor="controls-cv">CV</FormLabel>
      <FormFileInput name="controls-cv" accept=".pdf,.doc,.docx" />
    </FormGroup>
  )
}

FormDescriptionLink to this section

Help text as <p class="form-description">, in the form description type and color. Give it an id and reference it from the control's aria-describedby.

FormInvalidMessageLink to this section

The error text as <div class="form-invalid-message" role="alert">. The CSS keeps it hidden (display: none) until its FormGroup contains a control that is :user-invalid or carries is-invalid, so you can render it up front and let the browser reveal it. role="alert" makes screen readers announce it when it appears; pass another role to override.

Error message on an invalid control
import { FormGroup, FormInput, FormInvalidMessage, FormLabel } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormGroup style={{ width: '100%', maxWidth: 480 }}>
      <FormLabel htmlFor="controls-email">Email</FormLabel>
      <FormInput
        type="email"
        name="controls-email"
        defaultValue="jane@"
        required
        className="is-invalid"
        aria-invalid
        aria-describedby="controls-email-error"
      />
      <FormInvalidMessage id="controls-email-error">
        Enter an email address like jane@example.com.
      </FormInvalidMessage>
    </FormGroup>
  )
}

PropsLink to this section

FormLabelLink to this section

PropTypeDefaultDescription
htmlForstring-
…and all inherited HTML attributes

FormInputLink to this section

PropTypeDefaultDescription
autoCompletestring-
name (required)string-
idstring-
type (required)string-
…and all <input> attributes

FormTextareaLink to this section

PropTypeDefaultDescription
name (required)string-
idstring-
…and all <textarea> attributes

FormDescriptionLink to this section

PropTypeDefaultDescription
…and all <p> attributes

FormFileInputLink to this section

PropTypeDefaultDescription
idstring-
name (required)string-
acceptstring-
multipleboolean-
…and all <input> attributes

FormInvalidMessageLink to this section

PropTypeDefaultDescription
…and all <div> attributes

HTML and CSSLink to this section

<div class="form-group">
  <label class="form-label" for="email">Email</label>
  <p class="form-description" id="email-description">We reply within a day.</p>
  <input
    class="form-input"
    id="email"
    name="email"
    type="email"
    required
    aria-describedby="email-description email-error"
  />
  <div class="form-invalid-message" id="email-error" role="alert">Enter a valid email.</div>
</div>

<textarea class="form-textarea" id="notes" name="notes"></textarea>
<input class="form-input form-file-input" id="cv" name="cv" type="file" />

Add is-invalid to a control to force the invalid style and show the group's message without waiting for :user-invalid. The values come from the form tokens in spacing, colors and typography.

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 form controls from @systhemaui/react unchanged.

AccessibilityLink to this section

The primitives do not connect anything for you. When you compose them yourself:

  • Pair FormLabel htmlFor with the control's id (which defaults to name).
  • Put the ids of the description and the error message in the control's aria-describedby.
  • Set aria-invalid on the control while it is invalid. A field component does all three.