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.
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.
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.
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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
htmlFor | string | - | |
| …and all inherited HTML attributes |
FormInputLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
autoComplete | string | - | |
name (required) | string | - | |
id | string | - | |
type (required) | string | - | |
…and all <input> attributes |
FormTextareaLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
name (required) | string | - | |
id | string | - | |
…and all <textarea> attributes |
FormDescriptionLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
…and all <p> attributes |
FormFileInputLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | - | |
name (required) | string | - | |
accept | string | - | |
multiple | boolean | - | |
…and all <input> attributes |
FormInvalidMessageLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
…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.
| 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 controls from @systhemaui/react unchanged.
AccessibilityLink to this section
The primitives do not connect anything for you. When you compose them yourself:
- Pair
FormLabel htmlForwith the control'sid(which defaults toname). - Put the ids of the description and the error message in the control's
aria-describedby. - Set
aria-invalidon the control while it is invalid. A field component does all three.