Form Validation
Show validation feedback with browser defaults, custom styles, or compact tooltips — plus which form components support it and how to add validation icons.
Every form component accepts invalid/valid booleans plus invalidFeedback/validFeedback text
— see Working with Forms for that shared prop
surface. This page covers the four ways that state actually reaches the screen — browser-default
tooltips, custom Chassis styling, predetermined server-side state, and compact inline tooltips —
plus the opt-in validation icon and which components support each.
Browser defaults
With no extra configuration, a <Form> relies on the browser's own constraint validation: a form
with an invalid field, such as an empty required one, isn't submitted, and its first invalid field
takes focus. Every field that held the form back then shows as invalid, with its invalidFeedback
if it has one, until it no longer would or the form is reset. Set invalid on a field, to false
too, and that prop alone decides how it looks.
The browser's own message, a native tooltip beside the first invalid field, appears only on a field
that is a plain native control: Select, FileInput and a Switch with type="radio". The other
fields are built on react-aria, which cancels it. Its text comes from the browser, though it can
still be customized in JavaScript via
setCustomValidity(),
and it can't be styled with CSS. Reach for Custom styles when a design needs
Chassis's own feedback styling for every state.
import React from 'react'
import {
Button,
Checkbox,
Form,
FormLabel,
Grid,
GridItem,
InputGroup,
InputGroupAddon,
Select,
TextInput
} from '@chassis-ui/react'
export const Example = () => (
<Grid component={Form} gap="md">
<GridItem span={{ base: 'full', md: 4 }}>
<TextInput label="First name" defaultValue="Mark" required />
</GridItem>
<GridItem span={{ base: 'full', md: 4 }}>
<TextInput label="Last name" defaultValue="Otto" required />
</GridItem>
<GridItem span={{ base: 'full', md: 4 }}>
<FormLabel htmlFor="validationDefaultUsername" id="validationDefaultUsernameLabel">
Username
</FormLabel>
<InputGroup>
<InputGroupAddon>@</InputGroupAddon>
<TextInput
id="validationDefaultUsername"
aria-labelledby="validationDefaultUsernameLabel"
required
/>
</InputGroup>
</GridItem>
<GridItem span={{ base: 'full', md: 6 }}>
<TextInput label="City" required />
</GridItem>
<GridItem span={{ base: 'full', md: 3 }}>
<Select label="State" required>
<option disabled value="">
Choose...
</option>
<option>California</option>
<option>New York</option>
</Select>
</GridItem>
<GridItem span={{ base: 'full', md: 3 }}>
<TextInput label="Zip" required />
</GridItem>
<GridItem span="full">
<Checkbox label="Agree to terms and conditions" required />
</GridItem>
<GridItem span="full">
<Button color="primary" type="submit">
Submit form
</Button>
</GridItem>
</Grid>
)Custom styles
Set noValidate on <Form> to disable the browser's default feedback UI while still keeping the
Constraint Validation API available in JavaScript. The example below intercepts onSubmit, checks
form.checkValidity(), and sets validated on <Form> once the user has attempted to submit —
validated adds the .was-validated class Chassis's CSS uses to gate when :invalid/:valid
styling first appears, so a field isn't shown as invalid before the user has done anything.
import React, { useState } from 'react'
import {
Button,
Checkbox,
Form,
FormFeedback,
FormLabel,
Grid,
GridItem,
InputGroup,
InputGroupAddon,
Select,
TextInput
} from '@chassis-ui/react'
export const Example = () => {
const [validated, setValidated] = useState(false)
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const form = event.currentTarget
if (form.checkValidity() === false) {
event.preventDefault()
event.stopPropagation()
}
setValidated(true)
}
return (
<Grid component={Form} gap="md" noValidate validated={validated} onSubmit={handleSubmit}>
<GridItem span={{ base: 'full', md: 4 }}>
<TextInput label="First name" defaultValue="Mark" validFeedback="Looks good!" required />
</GridItem>
<GridItem span={{ base: 'full', md: 4 }}>
<TextInput label="Last name" defaultValue="Otto" validFeedback="Looks good!" required />
</GridItem>
<GridItem span={{ base: 'full', md: 4 }}>
<FormLabel htmlFor="validationCustomUsername" id="validationCustomUsernameLabel">
Username
</FormLabel>
<InputGroup>
<InputGroupAddon id="inputGroupPrepend">@</InputGroupAddon>
<TextInput
id="validationCustomUsername"
aria-labelledby="validationCustomUsernameLabel"
aria-describedby="inputGroupPrepend usernameFeedback"
required
/>
</InputGroup>
<FormFeedback id="usernameFeedback" invalid>
Please choose a username.
</FormFeedback>
</GridItem>
<GridItem span={{ base: 'full', md: 6 }}>
<TextInput label="City" invalidFeedback="Please provide a valid city." required />
</GridItem>
<GridItem span={{ base: 'full', md: 3 }}>
<Select label="State" invalidFeedback="Please select a valid state." required>
<option disabled value="">
Choose...
</option>
<option>California</option>
<option>New York</option>
</Select>
</GridItem>
<GridItem span={{ base: 'full', md: 3 }}>
<TextInput label="Zip" invalidFeedback="Please provide a valid zip." required />
</GridItem>
<GridItem span="full">
<Checkbox label="Agree to terms and conditions" aria-describedby="agreeFeedback" required />
<FormFeedback id="agreeFeedback" invalid>
You must agree before submitting.
</FormFeedback>
</GridItem>
<GridItem span="full">
<Button color="primary" type="submit">
Submit form
</Button>
</GridItem>
</Grid>
)
}Server-side
When validity is already known ahead of time — a server response, a prior submit attempt — set
invalid/valid directly instead of relying on native constraint validation. No noValidate or
submit handler is needed; the state renders as soon as the props are set.
A control's own invalidFeedback/validFeedback prop wires aria-describedby automatically.
Composing a control manually alongside an InputGroup or a standalone FormFeedback — as the
username and terms fields below do — means wiring aria-describedby to the feedback text's id
by hand instead. aria-describedby accepts more than one id, so reference an addon and a
feedback message together (see the username field).
import React from 'react'
import {
Button,
Checkbox,
Form,
FormFeedback,
FormLabel,
Grid,
GridItem,
InputGroup,
InputGroupAddon,
Select,
TextInput
} from '@chassis-ui/react'
export const Example = () => (
<Grid component={Form} gap="md">
<GridItem span={{ base: 'full', md: 4 }}>
<TextInput label="First name" defaultValue="Mark" validFeedback="Looks good!" valid />
</GridItem>
<GridItem span={{ base: 'full', md: 4 }}>
<TextInput label="Last name" defaultValue="Otto" validFeedback="Looks good!" valid />
</GridItem>
<GridItem span={{ base: 'full', md: 4 }}>
<FormLabel htmlFor="validationServerUsername" id="validationServerUsernameLabel">
Username
</FormLabel>
<InputGroup>
<InputGroupAddon id="inputGroupPrepend03">@</InputGroupAddon>
<TextInput
id="validationServerUsername"
aria-labelledby="validationServerUsernameLabel"
aria-describedby="inputGroupPrepend03 validationServerUsernameFeedback"
invalid
/>
</InputGroup>
<FormFeedback id="validationServerUsernameFeedback" invalid>
Please choose a username.
</FormFeedback>
</GridItem>
<GridItem span={{ base: 'full', md: 6 }}>
<TextInput label="City" invalidFeedback="Please provide a valid city." invalid />
</GridItem>
<GridItem span={{ base: 'full', md: 3 }}>
<Select label="State" invalidFeedback="Please select a valid state." invalid>
<option disabled value="">
Choose...
</option>
<option>California</option>
<option>New York</option>
</Select>
</GridItem>
<GridItem span={{ base: 'full', md: 3 }}>
<TextInput label="Zip" invalidFeedback="Please provide a valid zip." invalid />
</GridItem>
<GridItem span="full">
<Checkbox
label="Agree to terms and conditions"
aria-describedby="invalidCheckFeedback"
invalid
required
/>
<FormFeedback id="invalidCheckFeedback" invalid>
You must agree before submitting.
</FormFeedback>
</GridItem>
<GridItem span="full">
<Button color="primary" type="submit">
Submit form
</Button>
</GridItem>
</Grid>
)Supported elements
Base validation styling — state colors, border, focus ring, and feedback text — works on every
form component: text-like inputs, Select, Textarea, the toggle family (Checkbox, RadioGroup,
Switch), FileInput, ColorInput, RangeInput, ChipInput, Combobox, InputGroup-composed
fields, adorned inputs, and FloatingInput. A multiple Select still picks up the color/border
styling — only the validation icon is narrower than this list.
import React from 'react'
import {
Checkbox,
ChipInput,
ColorInput,
Combobox,
ComboboxItem,
FileInput,
FloatingInput,
FormFeedback,
FormLabel,
InputGroup,
InputAdorn,
InputGroupAddon,
RadioGroup,
Radio,
RangeInput,
Select,
Stack,
Switch,
Textarea,
TextInput
} from '@chassis-ui/react'
export const Example = () => (
<Stack gap="md" direction="vertical">
<TextInput label="Text input" invalid invalidFeedback="Example invalid feedback text." />
<Textarea
label="Textarea"
placeholder="Required example textarea"
invalid
invalidFeedback="Example invalid textarea feedback."
/>
<Select label="Select" invalid invalidFeedback="Example invalid select feedback.">
<option value="">Choose an option</option>
<option value="1">Option one</option>
<option value="2">Option two</option>
</Select>
<div>
<Checkbox label="Check this checkbox" aria-describedby="checkboxFeedback" invalid />
<FormFeedback id="checkboxFeedback" invalid>
Example invalid checkbox feedback.
</FormFeedback>
</div>
<RadioGroup
label="Radio"
name="validation-radio"
invalid
errorMessage="Example invalid radio feedback."
>
<Radio value="radio1" label="Toggle this radio" />
<Radio value="radio2" label="Or this other radio" />
</RadioGroup>
<div>
<Switch label="Check this switch" aria-describedby="switchFeedback" invalid />
<FormFeedback id="switchFeedback" invalid>
Example invalid switch feedback.
</FormFeedback>
</div>
<FileInput
label="File input"
aria-label="file example"
invalid
invalidFeedback="Example invalid file feedback."
/>
<ColorInput
label="Color input"
defaultValue="#3366ff"
invalid
invalidFeedback="Example invalid color feedback."
/>
<RangeInput label="Range input" invalid invalidFeedback="Example invalid range feedback." />
<div>
<FormLabel htmlFor="validationInputGroup" id="validationInputGroupLabel">
Input group
</FormLabel>
<InputGroup>
<InputGroupAddon>@</InputGroupAddon>
<TextInput
id="validationInputGroup"
aria-labelledby="validationInputGroupLabel"
placeholder="Username"
aria-describedby="inputGroupFeedback"
invalid
/>
</InputGroup>
<FormFeedback id="inputGroupFeedback" invalid>
Example invalid input group feedback.
</FormFeedback>
</div>
<TextInput
label="Input help"
adornStart={<InputAdorn>$</InputAdorn>}
adornEnd={<InputAdorn>.00</InputAdorn>}
invalid
invalidFeedback="Enter an amount greater than zero."
/>
<ChipInput
label="Chip input"
defaultValue={['HTML', 'CSS']}
invalid
invalidFeedback="Add at least one more skill."
/>
<Combobox
label="Combobox"
placeholder="Choose a role…"
invalid
invalidFeedback="Please pick a role."
>
<ComboboxItem id="admin">Admin</ComboboxItem>
<ComboboxItem id="editor">Editor</ComboboxItem>
<ComboboxItem id="viewer">Viewer</ComboboxItem>
</Combobox>
<FloatingInput
label="Floating label"
invalid
invalidFeedback="Example invalid floating label feedback."
>
<TextInput
type="email"
placeholder="name@example.com"
defaultValue="test@example.com"
invalid
/>
</FloatingInput>
</Stack>
)Validation icons
Add the validation-icons class to any ancestor — typically <Form>, but a .form-field,
InputGroup, or a single control works too — to render a success or error glyph inside every
supported descendant once it enters a validation state. Without that ancestor class, inputs still
pick up validation colors, border, and feedback text; only the icon is omitted.
The icon renders on text-like inputs, a single-value Select (not multiple, and not a size
greater than 1), ColorInput, ghost-input-based controls (ChipInput, and any TextInput/
Select using adornStart/adornEnd), and Combobox. Textarea, Checkbox, RadioGroup,
Switch, FileInput, and RangeInput never render one, by design — and a disabled control never
shows the icon even when it's otherwise eligible.
import React from 'react'
import {
ChipInput,
ColorInput,
Combobox,
ComboboxItem,
Form,
Select,
Stack,
Textarea,
TextInput
} from '@chassis-ui/react'
export const Example = () => (
<Form className="validation-icons">
<Stack direction="vertical" gap="md">
<TextInput label="Text input" defaultValue="John" valid validFeedback="Looks good!" />
<Select label="State" invalid invalidFeedback="Please select a valid state.">
<option disabled value="">
Choose...
</option>
<option>California</option>
<option>New York</option>
</Select>
<ColorInput label="Color" defaultValue="#3366ff" valid validFeedback="Looks good!" />
<ChipInput
label="Chip input"
defaultValue={['HTML', 'CSS']}
invalid
invalidFeedback="Add at least one more skill."
/>
<Combobox
label="Combobox"
placeholder="Choose a role…"
invalid
invalidFeedback="Please pick a role."
>
<ComboboxItem id="admin">Admin</ComboboxItem>
<ComboboxItem id="editor">Editor</ComboboxItem>
<ComboboxItem id="viewer">Viewer</ComboboxItem>
</Combobox>
<Textarea
label="Textarea (no icon by design)"
invalid
invalidFeedback="Textareas never render a validation icon."
/>
</Stack>
</Form>
)Set the Sass option $enable-validation-icons: false (see chassis-css's
Form Validation page) to strip the icon rules
from the compiled stylesheet entirely — validation-icons becomes a no-op at that point.
Tooltips
For compact layouts, swap FormFeedback's block-style feedback for a positioned tooltip by
passing it tooltip instead of relying on a control's own invalidFeedback/validFeedback shorthand
— that shorthand always renders the block style, so a tooltip needs FormFeedback composed
manually alongside a bare control, the same way the username field on this page composes
InputGroup. The tooltip positions itself against the nearest ancestor with position: relative
— add .position-relative to the element around the control, as each <GridItem> does here.
import React, { useState } from 'react'
import {
Button,
Form,
FormFeedback,
FormLabel,
Grid,
GridItem,
InputGroup,
InputGroupAddon,
Select,
TextInput
} from '@chassis-ui/react'
export const Example = () => {
const [validated, setValidated] = useState(false)
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const form = event.currentTarget
if (form.checkValidity() === false) {
event.preventDefault()
event.stopPropagation()
}
setValidated(true)
}
return (
<Grid component={Form} gap="md" noValidate validated={validated} onSubmit={handleSubmit}>
<GridItem span={{ base: 'full', md: 4 }} className="position-relative">
<FormLabel htmlFor="validationTooltip01" id="validationTooltip01Label">
First name
</FormLabel>
<TextInput
id="validationTooltip01"
aria-labelledby="validationTooltip01Label"
defaultValue="Mark"
required
/>
<FormFeedback tooltip valid>
Looks good!
</FormFeedback>
</GridItem>
<GridItem span={{ base: 'full', md: 4 }} className="position-relative">
<FormLabel htmlFor="validationTooltip02" id="validationTooltip02Label">
Last name
</FormLabel>
<TextInput
id="validationTooltip02"
aria-labelledby="validationTooltip02Label"
defaultValue="Otto"
required
/>
<FormFeedback tooltip valid>
Looks good!
</FormFeedback>
</GridItem>
<GridItem span={{ base: 'full', md: 4 }} className="position-relative">
<FormLabel htmlFor="validationTooltipUsername" id="validationTooltipUsernameLabel">
Username
</FormLabel>
<InputGroup>
<InputGroupAddon id="inputGroupPrependTooltip">@</InputGroupAddon>
<TextInput
id="validationTooltipUsername"
aria-labelledby="validationTooltipUsernameLabel"
aria-describedby="inputGroupPrependTooltip"
required
/>
</InputGroup>
<FormFeedback tooltip invalid>
Please choose a username.
</FormFeedback>
</GridItem>
<GridItem span={{ base: 'full', md: 6 }} className="position-relative">
<FormLabel htmlFor="validationTooltip03" id="validationTooltip03Label">
City
</FormLabel>
<TextInput id="validationTooltip03" aria-labelledby="validationTooltip03Label" required />
<FormFeedback tooltip invalid>
Please provide a valid city.
</FormFeedback>
</GridItem>
<GridItem span={{ base: 'full', md: 3 }} className="position-relative">
<FormLabel htmlFor="validationTooltip04">State</FormLabel>
<Select id="validationTooltip04" required>
<option disabled value="">
Choose...
</option>
<option>California</option>
<option>New York</option>
</Select>
<FormFeedback tooltip invalid>
Please select a valid state.
</FormFeedback>
</GridItem>
<GridItem span={{ base: 'full', md: 3 }} className="position-relative">
<FormLabel htmlFor="validationTooltip05" id="validationTooltip05Label">
Zip
</FormLabel>
<TextInput id="validationTooltip05" aria-labelledby="validationTooltip05Label" required />
<FormFeedback tooltip invalid>
Please provide a valid zip.
</FormFeedback>
</GridItem>
<GridItem span="full">
<Button color="primary" type="submit">
Submit form
</Button>
</GridItem>
</Grid>
)
}