Docs
Next

Form

The Form wrapper and the FormRow and FormGroup layout containers, with token gaps and percentage widths.

On this page

Form wraps a native <form> and spaces its children with the form tokens. FormRow puts fields side by side from the md breakpoint up, and FormGroup stacks a label, a control and its messages. The controls themselves are on their own pages:

  1. Field components pre-compose a label, a control, a description and an error message, and wire the ARIA attributes between them: TextField, EmailField and nine more. Start here.
  2. Primitives are the bare parts for layouts the fields don't cover: FormInput, FormLabel and friends, FormSelect, FormRadio and FormCheckbox.
Contact form
'use client'

import { Button, EmailField, Form, FormRow, TextField, TextareaField } from '@systhemaui/next'

export default function Demo() {
  return (
    <Form onSubmit={(event) => event.preventDefault()} style={{ width: '100%', maxWidth: 640 }}>
      <FormRow>
        <TextField
          name="contact-first-name"
          label="First name"
          autoComplete="given-name"
          required
        />
        <TextField name="contact-last-name" label="Last name" autoComplete="family-name" required />
      </FormRow>
      <EmailField
        name="contact-email"
        label="Email"
        required
        invalidMessage="Enter an email address like jane@example.com."
      />
      <TextareaField name="contact-message" label="Message" style={{ height: 140 }} />
      <Button.button type="submit" variant="primary">
        Send message
      </Button.button>
    </Form>
  )
}

ImportLink to this section

import { Form, FormGroup, FormRow } from '@systhemaui/next'

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

FormLink to this section

Form renders <form class="form"> and passes every <form> attribute through (action, method, onSubmit, noValidate, …). Its direct children are spaced by --form-row-gap-y; hidden inputs and hidden elements are skipped, so they add no gap.

Validation is the browser's: required, pattern, minLength and the input types decide validity, and the CSS styles a control as invalid through :user-invalid, which only matches after the visitor has interacted with it or tried to submit. Add noValidate when you validate in JavaScript, and mark failing controls with the is-invalid class (see Showing an error).

Unlike most components, a form does not reveal on scroll by default: disableAnimation defaults to true. Pass disableAnimation={false} to give it your animationClasses.

LayoutLink to this section

FormRowLink to this section

FormRow lays its groups out in a wrapping flex row with a --form-row-gap-x gap from the md breakpoint up, and stacks them below it. Each group takes an equal share of the row.

Two groups in a row
import { FormGroup, FormInput, FormLabel, FormRow } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormRow style={{ width: '100%', maxWidth: 640 }}>
      <FormGroup>
        <FormLabel htmlFor="row-city">City</FormLabel>
        <FormInput type="text" name="row-city" autoComplete="address-level2" />
      </FormGroup>
      <FormGroup>
        <FormLabel htmlFor="row-postcode">Postcode</FormLabel>
        <FormInput type="text" name="row-postcode" autoComplete="postal-code" />
      </FormGroup>
    </FormRow>
  )
}

FormGroupLink to this section

FormGroup is a plain <div class="form-group"> that spaces its children by --form-group-gap-y. When it contains a required control, its label gets a trailing asterisk in the required label color. The field components render a FormGroup for you.

WidthsLink to this section

Give a field a width (any CSS percentage) to size it inside a FormRow instead of sharing the row equally. The width is gap-aware: 50% and 50% fill one row exactly, and 33% plus 67% do too. Below md every field is full width again.

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

export default function Demo() {
  return (
    <div style={{ width: '100%', maxWidth: 640 }}>
      <FormRow>
        <TextField name="width-street" label="Street" width="67%" />
        <TextField name="width-number" label="Number" width="33%" />
        <TextField name="width-postcode" label="Postcode" width="33%" />
        <TextField name="width-city" label="City" width="67%" />
      </FormRow>
    </div>
  )
}

width only takes effect on a field that is a direct child of a FormRow. Outside a row a group is always full width.

PropsLink to this section

FormRow and FormGroup take every <div> attribute.

PropTypeDefaultDescription
classNamestring-
disableAnimationbooleantrue
children (required)ReactNode-
…and all <form> attributes

HTML and CSSLink to this section

<form class="form">
  <div class="form-row">
    <div class="form-group">
      <label class="form-label" for="first-name">First name</label>
      <input class="form-input" id="first-name" name="first-name" type="text" required />
    </div>
    <div class="form-group" style="--width: 33%">
      <label class="form-label" for="postcode">Postcode</label>
      <input class="form-input" id="postcode" name="postcode" type="text" />
    </div>
  </div>
  <button class="button-primary" type="submit">Send</button>
</form>

The --width custom property is what the width prop writes; the sizing rule only matches a .form-group whose inline style declares it. The gaps come from the form tokens in spacing.

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 components from @systhemaui/react unchanged. They hold no state and render on the server; add 'use client' to the module only where you attach event handlers such as onSubmit.

AccessibilityLink to this section

  • Every control needs a label. The field components render one; with primitives, pair FormLabel htmlFor with the control's id.
  • Group related controls (an address, a set of radios) and give the group a name; RadioField and CheckboxField do this with role="radiogroup" / role="group" and aria-labelledby.
  • The required asterisk is visual only. The required attribute is what assistive technology announces.

See Form fields for the ARIA wiring the fields add.