---
title: "Form controls"
description: "FormLabel, FormInput, FormTextarea, FormFileInput, FormDescription and FormInvalidMessage, the primitives behind every field."
url: https://docs.systhema.app/next/components/form-controls
version: unreleased (main)
docs_index: https://docs.systhema.app/next/llms.txt
---

The primitive form controls render one element each, styled with the form tokens. Use them for layouts the pre-composed [field components](https://docs.systhema.app/next/components/form-fields.md) 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.

```tsx preview title="Label, input and description"
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>
  )
}
```

## Import

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

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

## Controls

### `FormLabel`

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

### `FormInput`

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.

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

### `FormTextarea`

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

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

### `FormFileInput`

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.

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

### `FormDescription`

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

### `FormInvalidMessage`

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.

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

## Props

### `FormLabel`

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

| Prop                               | Type     | Default | Description |
| ---------------------------------- | -------- | ------- | ----------- |
| `htmlFor`                          | `string` | -       |             |
| …and all inherited HTML attributes |          |         |             |

<!-- /generated -->

### `FormInput`

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

| Prop                          | Type     | Default | Description |
| ----------------------------- | -------- | ------- | ----------- |
| `autoComplete`                | `string` | -       |             |
| `name` (required)             | `string` | -       |             |
| `id`                          | `string` | -       |             |
| `type` (required)             | `string` | -       |             |
| …and all `<input>` attributes |          |         |             |

<!-- /generated -->

### `FormTextarea`

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

| Prop                             | Type     | Default | Description |
| -------------------------------- | -------- | ------- | ----------- |
| `name` (required)                | `string` | -       |             |
| `id`                             | `string` | -       |             |
| …and all `<textarea>` attributes |          |         |             |

<!-- /generated -->

### `FormDescription`

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

| Prop                      | Type | Default | Description |
| ------------------------- | ---- | ------- | ----------- |
| …and all `<p>` attributes |      |         |             |

<!-- /generated -->

### `FormFileInput`

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

| Prop                          | Type      | Default | Description |
| ----------------------------- | --------- | ------- | ----------- |
| `id`                          | `string`  | -       |             |
| `name` (required)             | `string`  | -       |             |
| `accept`                      | `string`  | -       |             |
| `multiple`                    | `boolean` | -       |             |
| …and all `<input>` attributes |           |         |             |

<!-- /generated -->

### `FormInvalidMessage`

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

| Prop                        | Type | Default | Description |
| --------------------------- | ---- | ------- | ----------- |
| …and all `<div>` attributes |      |         |             |

<!-- /generated -->

## HTML and CSS

```html
<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](https://docs.systhema.app/next/reference/tokens/spacing.md#form), [colors](https://docs.systhema.app/next/reference/tokens/colors.md#form) and [typography](https://docs.systhema.app/next/reference/tokens/typography.md#text-styles).

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

## Accessibility

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](https://docs.systhema.app/next/components/form-fields.md#accessibility) does all three.

## Related

- [Form](https://docs.systhema.app/next/components/form.md)
- [Form fields](https://docs.systhema.app/next/components/form-fields.md)
- [Select](https://docs.systhema.app/next/components/form-select.md)
