---
title: "Form"
description: "The Form wrapper and the FormRow and FormGroup layout containers, with token gaps and percentage widths."
requested_language: nl
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/nl/next/components/form
version: unreleased (main)
docs_index: https://docs.systhema.app/nl/next/llms.txt
---
> This page isn't translated yet. Showing English.


`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](https://docs.systhema.app/nl/next/components/form-fields.md). Start here.
2. **Primitives** are the bare parts for layouts the fields don't cover: [`FormInput`, `FormLabel` and friends](https://docs.systhema.app/nl/next/components/form-controls.md), [`FormSelect`](https://docs.systhema.app/nl/next/components/form-select.md), [`FormRadio` and `FormCheckbox`](https://docs.systhema.app/nl/next/components/form-choice.md).

```tsx preview title="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>
  )
}
```

## Import

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

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

## `Form`

`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](https://docs.systhema.app/nl/next/components/form-fields.md#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`.

## Layout

### `FormRow`

`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.

```tsx preview title="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>
  )
}
```

### `FormGroup`

`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.

### Widths

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.

```tsx preview title="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.

## Props

`FormRow` and `FormGroup` take every `<div>` attribute.

<!-- generated:props @systhemaui/react FormProps -->

| Prop                         | Type        | Default | Description |
| ---------------------------- | ----------- | ------- | ----------- |
| `className`                  | `string`    | -       |             |
| `disableAnimation`           | `boolean`   | `true`  |             |
| `children` (required)        | `ReactNode` | -       |             |
| …and all `<form>` attributes |             |         |             |

<!-- /generated -->

## HTML and CSS

```html
<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](https://docs.systhema.app/nl/next/reference/tokens/spacing.md#form).

<!-- generated:utilities form -->

| Class                     | Styles                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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); } }`<br>`@media (width >= 640px) { & { display: flex; flex-wrap: wrap; gap: var(--form-row-gap-x); } }`<br>`@media (width >= 640px) { & > .form-group { flex: 1 1 0%; min-width: 0; } }`<br>`@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; } }`<br>`@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%)); } }`<br>`& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-group-gap-y); }`<br>`&:has(:required) .form-label::after { content: ' *'; color: var(--color-form-label-required); }`<br>+ 1 more rule                                                     |
| `form-label`              | `& { display: block; color: var(--color-form-label); }`<br>`& { 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); }`<br>+ 1 more rule                                                                        |
| `form-description`        | `& { display: block; color: var(--color-form-description); }`<br>`& { 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…`<br>+ 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…`<br>+ 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…`<br>+ 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…`<br>+ 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); }`<br>`:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; }`                                                                                                                                                                   |
| `form-select-wrapper`     | `position: 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); }`<br>+ 3 more rules                                                              |
| `search-field`            | `position: 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); }`<br>+ 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; }`<br>`& 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); }`<br>`& input[type="radio"], .form-radio  input[type="checkbox"] { opacity: 0; cursor: pointer; }`<br>`&:has(:checked):after { opacity: 1; }`<br>+ 13 more rules |
| `form-checkbox`           | `& { position: relative; user-select: none; }`<br>`& 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); }`<br>`& input[type="radio"], .form-checkbox  input[type="checkbox"] { opacity: 0; cursor: pointer; }`<br>+ 14 more rules                                   |
| `form-invalid-message`    | `& { display: none; color: var(--color-form-invalid-message); }`<br>`: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; }`<br>+ 2 more rules                                                                                                                                                                                                                                                                                                                                      |

<!-- /generated -->

## Next.js

`@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`.

## Accessibility

- 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](https://docs.systhema.app/nl/next/components/form-fields.md#accessibility) for the ARIA wiring the fields add.

## Related

- [Form fields](https://docs.systhema.app/nl/next/components/form-fields.md)
- [Form controls](https://docs.systhema.app/nl/next/components/form-controls.md)
- [Form block](https://docs.systhema.app/nl/next/payload/blocks/form.md) and the [Forms module](https://docs.systhema.app/nl/next/payload/forms.md)
