Skip to main contentSkip to docs navigation

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.

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

@
Please choose a username.
You must agree before submitting.
JSX
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).

Looks good!
Looks good!
@
Please choose a username.
Please provide a valid city.
Please select a valid state.
Please provide a valid zip.
You must agree before submitting.
JSX
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.

Example invalid feedback text.
Example invalid textarea feedback.
Example invalid select feedback.
Example invalid checkbox feedback.
Radio
Example invalid radio feedback.
Example invalid switch feedback.
Example invalid file feedback.
Example invalid color feedback.
Example invalid range feedback.
@
Example invalid input group feedback.
$.00
Enter an amount greater than zero.
HTML
CSS
Add at least one more skill.
Please pick a role.
Example invalid floating label feedback.
JSX
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.

Looks good!
Please select a valid state.
Looks good!
HTML
CSS
Add at least one more skill.
Please pick a role.
Textareas never render a validation icon.
JSX
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.

Looks good!
Looks good!
@
Please choose a username.
Please provide a valid city.
Please select a valid state.
Please provide a valid zip.
JSX
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>
  )
}