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:
- Field components pre-compose a label, a control, a description and an error message, and wire the ARIA attributes between them:
TextField,EmailFieldand nine more. Start here. - Primitives are the bare parts for layouts the fields don't cover:
FormInput,FormLabeland friends,FormSelect,FormRadioandFormCheckbox.
'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.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | |
disableAnimation | boolean | true | |
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.
| 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); } }@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-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); }+ 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); }+ 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 htmlForwith the control'sid. - Group related controls (an address, a set of radios) and give the group a name;
RadioFieldandCheckboxFielddo this withrole="radiogroup"/role="group"andaria-labelledby. - The required asterisk is visual only. The
requiredattribute is what assistive technology announces.
See Form fields for the ARIA wiring the fields add.