Form Validation
Provide clear, accessible feedback to users with Chassis CSS's form validation styles, utilizing HTML5 validation or custom implementations.
When using client-side custom validation styles and tooltips, additional work is needed to ensure proper accessibility. For better out-of-the-box accessibility, consider using server-side validation or native browser validation.
Understanding form validation
Chassis CSS provides flexible options for implementing form validation:
- Native validation: Uses the browser's built-in
:invalidand:validpseudo-classes to style form controls based on validation state. - Progressive enhancement: Chassis uses the
.was-validatedclass on forms to control when validation styles appear. This prevents invalid styles from showing when the page first loads. - Server-side fallback: The
.is-invalidand.is-validclasses provide direct control for server-side validation scenarios. - Custom validation messages: Add contextual feedback to users with
.valid-feedbackand.invalid-feedbackelements. - Opt-in icons: Add
.validation-iconsto a wrapper (e.g. the<form>) to render success/error glyphs inside every supported input it contains. Without it, only the colors, border, and shadow change. - API integration: All validation styles work with the browser's Constraint Validation API, allowing you to create custom validation logic with JavaScript.
- Accessible feedback: Position validation messages consistently outside input groups for optimal screen reader compatibility.
Client-side validation
For custom Chassis validation styles, add the novalidate attribute to your <form> element. This disables browser default tooltips but still allows JavaScript validation using the Constraint Validation API.
Key features of Chassis validation:
- Custom feedback styles with contextual colors and icons
- Support for all form controls including input groups
- Consistent positioning of feedback messages
- JavaScript integration for dynamic validation
<form class="row g-medium" data-cx-validate novalidate>
<div class="medium:col-4">
<label for="validationCustom01" class="form-label">First name</label>
<input type="text" class="form-input" id="validationCustom01" value="John" required>
<div class="valid-feedback">
Looks good!
</div>
</div>
<div class="medium:col-4">
<label for="validationCustom02" class="form-label">Last name</label>
<input type="text" class="form-input" id="validationCustom02" value="Smith" required>
<div class="valid-feedback">
Looks good!
</div>
</div>
<div class="medium:col-4">
<label for="validationCustomUsername" class="form-label">Username</label>
<div class="input-group">
<span class="input-addon" id="inputGroupPrepend">@</span>
<input type="text" class="form-input" id="validationCustomUsername" aria-describedby="inputGroupPrepend" required>
</div>
<div class="invalid-feedback">
Please choose a username.
</div>
</div>
<div class="medium:col-6">
<label for="validationCustom03" class="form-label">City</label>
<input type="text" class="form-input" id="validationCustom03" required>
<div class="invalid-feedback">
Please provide a valid city.
</div>
</div>
<div class="medium:col-3">
<label for="validationCustom04" class="form-label">State</label>
<select class="form-input" id="validationCustom04" required>
<option selected disabled value="">Choose...</option>
<option>California</option>
<option>New York</option>
<option>Texas</option>
</select>
<div class="invalid-feedback">
Please select a valid state.
</div>
</div>
<div class="medium:col-3">
<label for="validationCustom05" class="form-label">Zip</label>
<input type="text" class="form-input" id="validationCustom05" required>
<div class="invalid-feedback">
Please provide a valid zip.
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="check-input" type="checkbox" value="" id="invalidCheck" required>
<label class="check-label" for="invalidCheck">
Agree to terms and conditions
</label>
<div class="invalid-feedback">
You must agree before submitting.
</div>
</div>
</div>
<div class="col-12">
<button class="button primary" type="submit">Submit form</button>
</div>
</form>The example below shows the JavaScript needed to implement custom validation with Chassis CSS.
// Example starter JavaScript for preventing form submissions when there are invalid fields
;(() => {
'use strict'
const forms = document.querySelectorAll('form[data-cx-validate]')
for (const form of forms) {
form.addEventListener('submit', (event) => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
for (const control of form.elements) {
if (control.willValidate) {
control.setAttribute('aria-invalid', String(!control.validity.valid))
}
}
})
// Clear aria-invalid as users correct individual fields
form.addEventListener('input', (event) => {
const control = event.target
if (control.willValidate && control.hasAttribute('aria-invalid')) {
control.setAttribute('aria-invalid', String(!control.validity.valid))
}
})
}
})()
Native browser validation
If you prefer simpler implementation, you can use the browser's built-in validation. Chassis CSS works seamlessly with native validation UI, though these styles cannot be customized with CSS.
<form class="row g-medium">
<div class="medium:col-4">
<label for="validationDefault01" class="form-label">First name</label>
<input type="text" class="form-input" id="validationDefault01" value="John" required>
</div>
<div class="medium:col-4">
<label for="validationDefault02" class="form-label">Last name</label>
<input type="text" class="form-input" id="validationDefault02" value="Smith" required>
</div>
<div class="medium:col-4">
<label for="validationDefaultUsername" class="form-label">Username</label>
<div class="input-group">
<span class="input-addon" id="inputGroupPrepend2">@</span>
<input type="text" class="form-input" id="validationDefaultUsername" aria-describedby="inputGroupPrepend2" required>
</div>
</div>
<div class="medium:col-6">
<label for="validationDefault03" class="form-label">City</label>
<input type="text" class="form-input" id="validationDefault03" required>
</div>
<div class="medium:col-3">
<label for="validationDefault04" class="form-label">State</label>
<select class="form-input" id="validationDefault04" required>
<option selected disabled value="">Choose...</option>
<option>California</option>
<option>New York</option>
<option>Texas</option>
</select>
</div>
<div class="medium:col-3">
<label for="validationDefault05" class="form-label">Zip</label>
<input type="text" class="form-input" id="validationDefault05" required>
</div>
<div class="col-12">
<div class="form-check">
<input class="check-input" type="checkbox" value="" id="invalidCheck2" required>
<label class="check-label" for="invalidCheck2">
Agree to terms and conditions
</label>
</div>
</div>
<div class="col-12">
<button class="button primary" type="submit">Submit form</button>
</div>
</form>While native browser validation styles cannot be changed with CSS, you can customize the validation message text using JavaScript's setCustomValidity() method.
Server-side validation
For applications that validate on the server, Chassis CSS provides the .is-valid and .is-invalid state classes that can be applied to form controls directly, without needing the .was-validated parent class.
For invalid fields, always connect error messages to their inputs using aria-describedby to ensure screen readers announce feedback properly. Multiple IDs can be referenced if a field has both help text and error messages.
<form class="row g-medium">
<div class="medium:col-4">
<label for="validationServer01" class="form-label">First name</label>
<input type="text" class="form-input is-valid" id="validationServer01" value="John" required>
<div class="valid-feedback">
Looks good!
</div>
</div>
<div class="medium:col-4">
<label for="validationServer02" class="form-label">Last name</label>
<input type="text" class="form-input is-valid" id="validationServer02" value="Smith" required>
<div class="valid-feedback">
Looks good!
</div>
</div>
<div class="medium:col-4">
<label for="validationServerUsername" class="form-label">Username</label>
<div class="input-group has-validation">
<span class="input-addon" id="inputGroupPrepend3">@</span>
<input type="text" class="form-input is-invalid" id="validationServerUsername" aria-describedby="inputGroupPrepend3 validationServerUsernameFeedback" required>
</div>
<div id="validationServerUsernameFeedback" class="invalid-feedback">
Please choose a username.
</div>
</div>
<div class="medium:col-6">
<label for="validationServer03" class="form-label">City</label>
<input type="text" class="form-input is-invalid" id="validationServer03" aria-describedby="validationServer03Feedback" required>
<div id="validationServer03Feedback" class="invalid-feedback">
Please provide a valid city.
</div>
</div>
<div class="medium:col-3">
<label for="validationServer04" class="form-label">State</label>
<select class="form-input is-invalid" id="validationServer04" aria-describedby="validationServer04Feedback" required>
<option selected disabled value="">Choose...</option>
<option>California</option>
<option>New York</option>
<option>Texas</option>
</select>
<div id="validationServer04Feedback" class="invalid-feedback">
Please select a valid state.
</div>
</div>
<div class="medium:col-3">
<label for="validationServer05" class="form-label">Zip</label>
<input type="text" class="form-input is-invalid" id="validationServer05" aria-describedby="validationServer05Feedback" required>
<div id="validationServer05Feedback" class="invalid-feedback">
Please provide a valid zip.
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="check-input is-invalid" type="checkbox" value="" id="invalidCheck3" aria-describedby="invalidCheck3Feedback" required>
<label class="check-label" for="invalidCheck3">
Agree to terms and conditions
</label>
<div id="invalidCheck3Feedback" class="invalid-feedback">
You must agree before submitting.
</div>
</div>
</div>
<div class="col-12">
<button class="button primary" type="submit">Submit form</button>
</div>
</form>Supported form elements
Chassis CSS validation styles work consistently across all basic form elements:
<form class="vstack gap-medium">
<div class="form-field">
<label for="validationSelect" class="form-label">Select</label>
<select class="form-input is-invalid" id="validationSelect" required aria-describedby="validationSelectFeedback">
<option value="">Choose an option</option>
<option value="1">Option One</option>
<option value="2">Option Two</option>
<option value="3">Option Three</option>
</select>
<div class="invalid-feedback" id="validationSelectFeedback">Example invalid select feedback</div>
</div>
<div class="form-field">
<label for="validationTextarea" class="form-label">Textarea</label>
<textarea class="form-input is-invalid" id="validationTextarea" placeholder="Required example textarea" required aria-describedby="validationTextareaFeedback"></textarea>
<div class="invalid-feedback" id="validationTextareaFeedback">
Example invalid textarea feedback
</div>
</div>
<div class="form-field">
<div class="form-label">Checkbox</div>
<div class="form-check">
<input type="checkbox" class="check-input is-invalid" id="validationCheckboxLegacy" name="checkbox-stacked-legacy" required aria-describedby="validationCheckboxLegacyFeedback">
<label for="validationCheckboxLegacy">Check this legacy checkbox</label>
<div class="invalid-feedback" id="validationCheckboxLegacyFeedback">Example invalid checkbox legacy feedback</div>
</div>
<div class="form-check">
<span class="check-input default">
<input type="checkbox" class="is-invalid" id="validationCheckboxModern" name="checkbox-stacked" required aria-describedby="validationCheckboxModernFeedback">
</span>
<label for="validationCheckboxModern">Check this modern checkbox</label>
<div class="invalid-feedback" id="validationCheckboxModernFeedback">Example invalid checkbox modern feedback</div>
</div>
</div>
<div class="form-field">
<div class="form-label">Radio</div>
<div class="form-check">
<input type="radio" class="check-input is-invalid" id="validationRadioLegacy" name="radio-stacked" required aria-describedby="validationRadioFeedback">
<label for="validationRadioLegacy">Toggle this legacy radio</label>
</div>
<div class="form-check">
<span class="check-input default">
<input type="radio" class="is-invalid" id="validationRadioModern" name="radio-stacked" required aria-describedby="validationRadioFeedback">
</span>
<label for="validationRadioModern">Toggle this modern radio</label>
</div>
<div class="invalid-feedback" id="validationRadioFeedback">Example invalid radio feedback</div>
</div>
<div class="form-field">
<div class="form-label">Switch</div>
<div class="form-check">
<input type="checkbox" class="check-input is-invalid" role="switch" id="validationSwitchLegacy" required aria-describedby="validationSwitchLegacyFeedback">
<label for="validationSwitchLegacy">Check this legacy switch</label>
<div class="invalid-feedback" id="validationSwitchLegacyFeedback">Example invalid legacy switch feedback</div>
</div>
<div class="form-check">
<span class="check-input default">
<input type="checkbox" class="is-invalid" role="switch" id="validationSwitchModern" required aria-describedby="validationSwitchModernFeedback">
</span>
<label for="validationSwitchModern">Check this modern switch</label>
<div class="invalid-feedback" id="validationSwitchModernFeedback">Example invalid modern switch feedback</div>
</div>
</div>
<div class="form-field">
<label for="validationFile" class="form-label">File input</label>
<input type="file" class="form-input is-invalid" id="validationFile" aria-label="file example" required aria-describedby="validationFileFeedback">
<div class="invalid-feedback" id="validationFileFeedback">Example invalid form file feedback</div>
</div>
<div class="form-field">
<label for="validationColor" class="form-label">Color input</label>
<input type="color" class="form-input is-invalid" id="validationColor" aria-label="color example" aria-describedby="validationColorFeedback">
<div class="invalid-feedback" id="validationColorFeedback">Example invalid color feedback</div>
</div>
<div class="form-field">
<label for="validationRange" class="form-label">Range input</label>
<input type="range" class="form-range is-invalid" id="validationRange" aria-label="range example" aria-describedby="validationRangeFeedback">
<div class="invalid-feedback" id="validationRangeFeedback">Example invalid range feedback</div>
</div>
<div class="form-field">
<div class="form-label">Floating label</div>
<div class="form-floating">
<label for="validationFloating">Email address</label>
<input type="email" class="form-input is-invalid" id="validationFloating" placeholder="name@example.com" value="test@example.com" aria-describedby="validationFloatingFeedback">
</div>
<div id="validationFloatingFeedback" class="invalid-feedback">Example invalid floating label feedback</div>
</div>
<div class="form-field">
<label for="validationInputGroup" class="form-label">Input group</label>
<div class="input-group">
<span class="input-addon">@</span>
<input type="text" class="form-input is-invalid" id="validationInputGroup" placeholder="Username" aria-describedby="validationInputGroupFeedback">
</div>
<div id="validationInputGroupFeedback" class="invalid-feedback">Example invalid input group feedback</div>
</div>
<div class="form-field">
<label for="validationInputHelp" class="form-label">Input Help</label>
<div class="form-input">
<div class="input-help">
<svg class="icon" aria-hidden="true"><use href="#search-outline"></use></svg>
</div>
<input type="text" class="ghost-input is-invalid" id="validationInputHelp" placeholder="Search..." aria-describedby="validationInputHelpFeedback" required>
</div>
<div class="invalid-feedback" id="validationInputHelpFeedback">Example invalid input help feedback</div>
</div>
<div class="form-field">
<label for="validationChipInput" class="form-label">Chip Input</label>
<div class="form-input chip-input" data-cx-chips="default smooth">
<span class="chip" data-cx-chip-value="example">Example</span>
<input type="text" class="ghost-input is-invalid" placeholder="Add more…" id="validationChipInput" aria-describedby="validationChipInputFeedback" required>
</div>
<div class="invalid-feedback" id="validationChipInputFeedback">Example invalid chip input feedback</div>
</div>
<div class="form-field">
<label for="validationCombobox" class="form-label">Role</label>
<div class="form-input combobox"
data-cx-toggle="combobox"
data-cx-name="role">
<input type="text" class="combobox-value is-invalid" placeholder="Choose an option…" autocomplete="off" id="validationCombobox" aria-describedby="validationComboboxFeedback" required>
</div>
<div class="menu" role="listbox">
<button class="menu-item" type="button" role="option" data-cx-value="admin">Admin</button>
<button class="menu-item" type="button" role="option" data-cx-value="editor">Editor</button>
<button class="menu-item" type="button" role="option" data-cx-value="viewer">Viewer</button>
</div>
<div class="invalid-feedback" id="validationComboboxFeedback">Example invalid combobox feedback</div>
</div>
<div>
<button class="button primary" type="submit" disabled>Submit form</button>
</div>
</form>Tooltip validation
For compact layouts, Chassis CSS provides tooltip-style validation feedback. Simply replace .valid-feedback and .invalid-feedback with .valid-tooltip and .invalid-tooltip classes.
Tooltips require a parent element with position: relative. The column classes in our examples already have this, but you may need to add it to your own layouts.
<form class="row g-medium" data-cx-validate novalidate>
<div class="medium:col-4 position-relative">
<label for="validationTooltip01" class="form-label">First name</label>
<input type="text" class="form-input" id="validationTooltip01" value="John" required>
<div class="valid-tooltip">
Looks good!
</div>
</div>
<div class="medium:col-4 position-relative">
<label for="validationTooltip02" class="form-label">Last name</label>
<input type="text" class="form-input" id="validationTooltip02" value="Smith" required>
<div class="valid-tooltip">
Looks good!
</div>
</div>
<div class="medium:col-4 position-relative">
<label for="validationTooltipUsername" class="form-label">Username</label>
<div class="input-group has-validation">
<span class="input-addon" id="validationTooltipUsernamePrepend">@</span>
<input type="text" class="form-input" id="validationTooltipUsername" aria-describedby="validationTooltipUsernamePrepend" required>
</div>
<div class="invalid-tooltip">
Please choose a unique and valid username.
</div>
</div>
<div class="medium:col-6 position-relative">
<label for="validationTooltip03" class="form-label">City</label>
<input type="text" class="form-input" id="validationTooltip03" required>
<div class="invalid-tooltip">
Please provide a valid city.
</div>
</div>
<div class="medium:col-3 position-relative">
<label for="validationTooltip04" class="form-label">State</label>
<select class="form-input" id="validationTooltip04" required>
<option selected disabled value="">Choose...</option>
<option>California</option>
<option>New York</option>
<option>Texas</option>
</select>
<div class="invalid-tooltip">
Please select a valid state.
</div>
</div>
<div class="medium:col-3 position-relative">
<label for="validationTooltip05" class="form-label">Zip</label>
<input type="text" class="form-input" id="validationTooltip05" required>
<div class="invalid-tooltip">
Please provide a valid zip.
</div>
</div>
<div class="col-12">
<button class="button primary" type="submit">Submit form</button>
</div>
</form>Validation icons
Validation icons are opt-in at the wrapper level. Add the .validation-icons class to any ancestor — typically the <form>, but it also works on .form-field, .input-group, or even a single .form-input — and every supported descendant input renders the success or error glyph when it enters a validation state. Without an ancestor with this class, inputs still pick up validation colors, border, and shadow — only the icon is omitted.
Icons appear on text inputs, single-value <select> elements, color inputs, .ghost-input (used by .chip-input and the .input-help wrapper), and .combobox-value. Textareas, checkboxes, radios, switches, file, and range inputs do not receive icons by design.
<form class="vstack gap-medium validation-icons" data-cx-validate novalidate>
<div class="form-field">
<label for="validationIconsText" class="form-label">Text input</label>
<input type="text" class="form-input" id="validationIconsText" value="John" required>
<div class="valid-feedback">Looks good!</div>
</div>
<div class="form-field">
<label for="validationIconsSelect" class="form-label">Select</label>
<select class="form-input" id="validationIconsSelect" required>
<option selected disabled value="">Choose...</option>
<option>California</option>
<option>New York</option>
<option>Texas</option>
</select>
<div class="invalid-feedback">Please select a valid state.</div>
</div>
<div class="form-field">
<label for="validationIconsColor" class="form-label">Color</label>
<input type="color" class="form-input" id="validationIconsColor" value="#3366ff">
<div class="valid-feedback">Looks good!</div>
</div>
<div class="form-field">
<label for="validationIconsInputHelp" class="form-label">Input help</label>
<div class="form-input">
<span class="input-help">$</span>
<input type="number" class="ghost-input" id="validationIconsInputHelp" placeholder="0.00" min="1" required>
<span class="input-help">USD</span>
</div>
<div class="invalid-feedback">Enter an amount greater than zero.</div>
</div>
<div class="form-field">
<label for="validationIconsChipInput" class="form-label">Chip input</label>
<div class="form-input chip-input" data-cx-chips="primary smooth">
<span class="chip" data-cx-chip-value="HTML">HTML</span>
<span class="chip" data-cx-chip-value="CSS">CSS</span>
<input type="text" class="ghost-input" id="validationIconsChipInput" placeholder="Add a skill…" required>
</div>
<div class="invalid-feedback">Add at least one more skill.</div>
</div>
<div class="form-field">
<label for="validationIconsCombobox" class="form-label">Combobox</label>
<div class="form-input combobox" data-cx-toggle="combobox" data-cx-name="role">
<input type="text" class="combobox-value" id="validationIconsCombobox" placeholder="Choose a role…" autocomplete="off" required>
</div>
<div class="menu" role="listbox">
<button class="menu-item" type="button" role="option" data-cx-value="admin">Admin</button>
<button class="menu-item" type="button" role="option" data-cx-value="editor">Editor</button>
<button class="menu-item" type="button" role="option" data-cx-value="viewer">Viewer</button>
</div>
<div class="invalid-feedback">Please pick a role.</div>
</div>
<div>
<button class="button primary" type="submit">Submit form</button>
</div>
</form>Set the Sass option $enable-validation-icons: false to strip the icon rules from the compiled bundle entirely. The .validation-icons class becomes a no-op when icons are disabled at compile time.
Persistent Form help
The .form-help component is hidden by default when validation feedback is present so the active message takes precedence. Add the .always-show class to keep help text visible alongside validation feedback. Always place form help and validation feedback elements as siblings after the input or input group.
Accessibility
Validation feedback only works for every user when it's wired up with the right ARIA attributes. The example below demonstrates the recommended pattern for dynamic, JavaScript-driven validation — connecting the input to its feedback with aria-describedby, announcing changes through a live region, and toggling aria-invalid in sync with .is-invalid.
Because .invalid-feedback and .valid-feedback are hidden with display: none when inactive, you can safely list all relevant IDs in a single static aria-describedby — screen readers skip display: none elements, so only the currently visible description is announced on focus. Add aria-live="polite" to feedback elements so errors injected via JavaScript are announced immediately without requiring the user to re-focus the field. Always set aria-invalid="true" on the input when it is invalid, and remove it when the error is cleared.
<div class="input-group">
<span class="input-addon">@</span>
<input type="text" class="form-input" id="username-input"
aria-describedby="username-feedback username-help" placeholder="Username" required>
</div>
<div class="invalid-feedback" id="username-feedback" aria-live="polite"></div>
<div class="form-help always-show" id="username-help">Enter your username</div>
<div class="mt-medium">
<button class="button primary" type="button" onclick="validateUsername()">Validate username</button>
</div>function validateUsername() {
const input = document.getElementById("username-input")
const feedback = document.getElementById("username-feedback")
const invalid = !input.validity.valid
input.classList.toggle("is-invalid", invalid)
if (invalid) {
input.setAttribute("aria-invalid", "true")
feedback.textContent = input.validationMessage
} else {
input.removeAttribute("aria-invalid")
feedback.textContent = ""
}
}CSS
Chassis CSS uses a powerful combination of Sass and CSS variables to create a flexible form system that adapts to themes and design requirements.
Custom properties
Check the overview page to see the CSS variables shared by all form elements.
Sass variables
This component uses Sass variables in scss/config/_defaults.scss to define its defaults.
$form-valid-class: is-valid;
$form-invalid-class: is-invalid;
$form-valid-tooltip-fg-color: var(--success-fg-solid);
$form-valid-tooltip-bg-color: var(--success-bg-solid);
$form-invalid-tooltip-fg-color: var(--danger-fg-solid);
$form-invalid-tooltip-bg-color: var(--danger-bg-solid);
$form-valid-focus-ring-color: light-dark(to-opacity(var(--success-cue-main), var(--focus-ring-light-opacity)), to-opacity(var(--success-cue-main), var(--focus-ring-dark-opacity)));
$form-invalid-focus-ring-color: light-dark(to-opacity(var(--danger-cue-main), var(--focus-ring-light-opacity)), to-opacity(var(--danger-cue-main), var(--focus-ring-dark-opacity)));
$form-feedback-icon-size: var(--icon-size);
$form-feedback-icon-input-position: center var(--logical-position-end, right) var(--padding-x);
$form-feedback-icon-input-padding-e: calc($form-feedback-icon-size + var(--padding-x) + var(--gap));
$form-feedback-icon-select-position: center var(--logical-position-end, right) calc(var(--padding-x) + var(--gap) * .5 + var(--caret-size));
$form-feedback-icon-select-padding-e: calc($form-feedback-icon-size + var(--padding-x) + var(--gap) * 1.5 + var(--caret-size));Design tokens
This component consumes design tokens from Chassis Tokens with the $cx- prefix. — See thedesign tokens page.
The following design tokens define the validation icons and sizes:
$form-feedback-valid-icon: $cx-icon-form-input-feedback-valid;
$form-feedback-invalid-icon: $cx-icon-form-input-feedback-invalid;
$form-medium-icon-size: $cx-size-form-input-medium-icon;
$form-large-icon-size: $cx-size-form-input-large-icon;
$form-small-icon-size: $cx-size-form-input-small-icon;Check the overview page to see the design tokens shared by all form elements.