Field
Fields represent an individual section of a form containing an associated control and label, as well as any description or validation messages.
Your name will be visible on your profile.
'use client';
import * as React from 'react';
import { Field } from '@base_ui/react/Field';
import { styled } from '@mui/system';
export default function UnstyledFieldIntroduction() {
return (
<FieldRoot validate={(value) => (value === 'admin' ? 'Name not allowed' : null)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Field.Label>Name</Field.Label>
<FieldControl required pattern="[a-zA-Z0-9]+" />
</div>
<Field.Validity>
{({ validity, value }) => {
if (
validity.valueMissing ||
validity.patternMismatch ||
value === 'admin'
) {
return null;
}
return (
<FieldDescription>
Your name will be visible on your profile.
</FieldDescription>
);
}}
</Field.Validity>
<FieldError match="customError" />
<FieldError match="valueMissing" />
<FieldError match="patternMismatch">
Only alphanumeric characters are allowed (a-z, A-Z, 0-9).
</FieldError>
</FieldRoot>
);
}
const FieldRoot = styled(Field.Root)`
width: 275px;
`;
const FieldControl = styled(Field.Control)`
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
padding: 6px;
font-size: 100%;
&[data-field='invalid'] {
border-color: red;
background-color: rgb(255 0 0 / 0.1);
}
&:focus {
outline: 0;
border-color: #0078d4;
box-shadow: 0 0 0 3px rgba(0 100 255 / 0.3);
&[data-field='invalid'] {
border-color: red;
box-shadow: 0 0 0 3px rgba(255 0 0 / 0.3);
}
}
`;
const FieldDescription = styled(Field.Description)`
font-size: 90%;
margin-bottom: 0;
margin-top: 4px;
line-height: 1.1;
color: grey;
&[data-error] {
color: red;
}
`;
const FieldError = styled(Field.Error)`
display: block;
font-size: 90%;
margin: 0;
margin-bottom: 0;
margin-top: 4px;
line-height: 1.1;
color: red;
`;
Installation
Base UI components are all available as a single package.
npm install @base_ui/react
Once you have the package installed, import the component.
import { Field } from '@base_ui/react/Field';
Anatomy
Fields are implemented using a collection of related components:
<Field.Root />
is a top-level component that wraps all other components.<Field.Control />
renders the control when not using a native Base UI input component.<Field.Label />
renders a label for the control.<Field.Description />
renders an optional description for the control to provide additional information.<Field.Error />
renders error messages for the control.<Field.Validity />
accepts a function as a child that enables reading rawValidityState
to render custom JSX.
Labeling and descriptive help text
All Base UI input components are aware of Base UI's Field
component. The label and description are automatically wired to these components when placed inside a Field.Root
:
When using a native control like input
or a custom component which is not aware of Base UI's Field
, use Field.Control
:
The render
prop allows you to pass a custom component or tag, different from the default of input
:
Validation
When adding native HTML validation props like required
or pattern
, Field.Error
renders error messages inside of it automatically:
The children
by default is the browser's native message, which is automatically internationalized. You may pass custom children
instead:
Individual constraint validation failures
When there are multiple HTML validation props, you can target individual validity state failures using the match
prop to render custom messages:
For the list of supported match
strings, visit ValidityState
on MDN.
Custom validation
In addition to the native HTML constraint validation, custom validation can be used by specifying a validate
function on Field.Root
. It receives the control's value
as its argument, and returns an error string or array of error strings if the field is invalid, or null
otherwise.
For Base UI input components, value
represents the component's value type, while for native
elements, it is always the native element.value
DOM property. Attach a ref
to the Control
element and access it to read its state inside the validate
function for further control as an
alternative if necessary.
To customize the rendering of multiple messages, you can use the Validity
subcomponent:
The Validity
subcomponent enables rendering custom JSX based on the state
parameter, which contains the following properties:
state.validity
, the field'sValidityState
state.errors
, an array of custom errors returned from thevalidate
prop (if present)state.error
, a custom error string returned from thevalidate
prop (if present)state.value
, the field control's current valuestate.initialValue
, the field control's initial value upon mount
It can be placed anywhere inside Field.Root
, including other Field subcomponents.
Controlled validity
When the invalid
prop is applied to Field.Root
, the Field is placed into an invalid state regardless of client-side validation. In this state, a given Field.Error
message can be forced to be shown by specifying a forceShow
prop.
This is useful for server-side error messages, or displaying errors initially during SSR phase.
The show
prop is for client-side validation, while the forceShow
prop is for server-side validation. Both can be combined together to share the same error message.
Performing an email validity check on the server:
'use client';
import * as React from 'react';
import { Field } from '@base_ui/react/Field';
import { styled } from '@mui/system';
type Status = 'initial' | 'loading' | 'success' | 'error';
export default function UnstyledFieldServerError() {
const [error, setError] = React.useState(false);
const [status, setStatus] = React.useState<Status>('initial');
const controlRef = React.useRef<HTMLInputElement>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (error || !controlRef.current?.validity.valid) {
return;
}
const formData = new FormData(event.currentTarget);
const email = formData.get('email') as string;
setStatus('loading');
// Mimic a server request
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
if (email && email.endsWith('@example.com')) {
setStatus('error');
setError(true);
} else {
setStatus('success');
}
controlRef.current?.focus();
}
return (
<form onSubmit={handleSubmit} noValidate>
<FieldRoot invalid={error} name="email">
<Field.Label>Email address</Field.Label>
<FieldControl
ref={controlRef}
type="email"
required
onChange={() => {
setStatus('initial');
setError(false);
}}
/>
<Field.Validity>
{(state) => (
<FieldSubmit
type="submit"
aria-disabled={status === 'loading'}
// The aria-description attribute is not a standard ARIA attribute (it's defined in ARIA 1.3 Editor's Draft).
// eslint-disable-next-line jsx-a11y/aria-props
aria-description={
!state.validity.valid ? 'Field has errors' : undefined
}
onClick={(event) => {
if (status === 'loading') {
event.preventDefault();
}
}}
>
{status === 'loading' ? 'Processing...' : 'Change email'}
</FieldSubmit>
)}
</Field.Validity>
<FieldError />
<FieldError forceShow={error}>@example.com is not allowed</FieldError>
{status === 'success' && (
<FieldSuccess role="alert" aria-live="polite">
Email changed successfully
</FieldSuccess>
)}
<FieldDescription>
On the client, standard email validation is performed. On the server, we
check a blocklist of email domains: the blocked domain is @example.com.
</FieldDescription>
</FieldRoot>
</form>
);
}
const FieldRoot = styled(Field.Root)`
width: 275px;
`;
const FieldControl = styled(Field.Control)`
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
padding: 6px;
font-size: 100%;
&[data-field='invalid'] {
border-color: red;
background-color: rgb(255 0 0 / 0.1);
}
&:focus {
outline: 0;
border-color: #0078d4;
box-shadow: 0 0 0 3px rgba(0 100 255 / 0.3);
&[data-field='invalid'] {
border-color: red;
box-shadow: 0 0 0 3px rgba(255 0 0 / 0.3);
}
}
`;
const FieldError = styled(Field.Error)`
display: block;
font-size: 90%;
margin: 0;
padding: 0;
margin-top: 10px;
line-height: 1.1;
&[data-dirty],
&[data-touched] {
color: red;
}
`;
const FieldSuccess = styled(Field.Description)`
font-size: 90%;
margin: 0;
padding: 0;
margin-top: 10px;
color: green;
`;
const FieldDescription = styled('p')`
font-size: 90%;
margin: 0;
padding: 0;
margin-top: 10px;
line-height: 1.1;
color: grey;
`;
const FieldSubmit = styled('button')`
display: block;
margin-top: 10px;
padding: 10px;
width: 100%;
font-size: 100%;
background-color: #0078d4;
color: white;
border: none;
border-radius: 4px;
&[aria-disabled='true'] {
background-color: #ddd;
color: black;
}
`;
Errors shown initially for password validation:
- Password must be at least 8 characters long.
- Password must contain at least 2 uppercase letters.
- Password must contain at least 2 unique symbols from the set [!@#$%^&*].
'use client';
import * as React from 'react';
import { Field } from '@base_ui/react/Field';
import { styled } from '@mui/system';
function validate(value: string) {
const password = value;
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long.');
}
if ((password.match(/[A-Z]/g) ?? []).length < 2) {
errors.push('Password must contain at least 2 uppercase letters.');
}
if ((password.match(/[!@#$%^&*]/g) ?? []).length < 2) {
errors.push(
'Password must contain at least 2 unique symbols from the set [!@#$%^&*].',
);
}
return errors;
}
export default function UnstyledFieldPassword() {
const [value, setValue] = React.useState('');
const errors = validate(value);
return (
<FieldRoot invalid={errors.length > 0}>
<Field.Label>Password</Field.Label>
<FieldControl
type="password"
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<FieldError forceShow>
<ul>
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
</FieldError>
</FieldRoot>
);
}
const FieldRoot = styled(Field.Root)`
width: 275px;
`;
const FieldControl = styled(Field.Control)`
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
padding: 6px;
font-size: 100%;
&[data-field='valid'][data-dirty] {
border-color: green;
background-color: rgb(0 255 0 / 0.1);
}
&[data-field='invalid'][data-touched][data-dirty] {
border-color: red;
background-color: rgb(255 0 0 / 0.1);
}
&:focus {
outline: 0;
border-color: #0078d4;
box-shadow: 0 0 0 3px rgba(0 100 255 / 0.3);
&[data-field='valid'][data-dirty] {
border-color: green;
box-shadow: 0 0 0 3px rgba(100 200 100 / 0.3);
}
&[data-field='invalid'][data-touched][data-dirty] {
border-color: red;
box-shadow: 0 0 0 3px rgba(255 0 0 / 0.3);
}
}
`;
const FieldError = styled(Field.Error)`
display: block;
font-size: 90%;
margin-top: 10px;
line-height: 1.1;
&[data-touched][data-dirty] {
color: red;
}
ul {
padding: 0;
}
`;
Realtime and async validation
validationMode="onChange"
reports the validity of the control on every change
event instead of blur
:
The validate
function can also be async by returning a promise, enabling inline server-side validation through network requests.
In the demo below, the taken names are admin
, root
, and superuser
— every other name is available. For demonstration purposes, a fake network request that takes 500ms is initiated to mimic a trip to the server to check for availability on the back-end.
Handle availability checker
Enter a name
'use client';
import * as React from 'react';
import { Field } from '@base_ui/react/Field';
import { styled } from '@mui/system';
const cache = new Map<string, string | null>();
function checkAvailability(name: string) {
const takenNames = ['admin', 'root', 'superuser'];
return new Promise<string | null>((resolve) => {
setTimeout(() => {
const result = takenNames.includes(name) ? 'Name taken' : null;
cache.set(name, result);
resolve(result);
}, 500);
});
}
export default function UnstyledFieldAsync() {
const [loading, setLoading] = React.useState(false);
async function handleValidate(value: unknown) {
const name = value as string;
if (name === '') {
return null;
}
const isCached = cache.has(name);
if (isCached) {
return cache.get(name) as string | null;
}
setLoading(true);
try {
const error = await checkAvailability(name);
setLoading(false);
return error;
} catch {
setLoading(false);
return 'Failed to fetch name availability';
}
}
return (
<div>
<h3>Handle availability checker</h3>
<FieldRoot
validate={handleValidate}
validationMode="onChange"
validationDebounceTime={300}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Field.Label>@</Field.Label>
<Field.Validity>
{(state) => (
<FieldControl
data-pending={state.value === '' || loading || undefined}
/>
)}
</Field.Validity>
</div>
<Field.Validity>
{(state) => {
if (loading) {
return <FieldDescription>Checking availability...</FieldDescription>;
}
if (!state.value) {
return <FieldDescription>Enter a name</FieldDescription>;
}
if (!state.validity.customError) {
return (
<FieldDescription
data-type="success"
role="alert"
aria-live="polite"
>
<strong>@{state.value as string}</strong> is available
</FieldDescription>
);
}
return <FieldError match="customError" />;
}}
</Field.Validity>
</FieldRoot>
</div>
);
}
const FieldRoot = styled(Field.Root)`
width: 275px;
`;
const FieldControl = styled(Field.Control)`
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
padding: 6px;
font-size: 100%;
&[data-field='invalid']:not([data-pending]) {
border-color: red;
background-color: rgb(255 0 0 / 0.1);
}
&[data-field='valid']:not([data-pending]) {
border-color: green;
background-color: rgb(0 255 0 / 0.1);
}
&:focus {
outline: 0;
border-color: #0078d4;
box-shadow: 0 0 0 3px rgba(0 100 255 / 0.3);
&[data-field='invalid']:not([data-pending]) {
border-color: red;
box-shadow: 0 0 0 3px rgba(255 0 0 / 0.3);
}
&[data-field='valid']:not([data-pending]) {
box-shadow: 0 0 0 3px rgba(100 200 100 / 0.3);
}
}
`;
const FieldDescription = styled(Field.Description)`
font-size: 90%;
margin: 0;
margin-top: 4px;
line-height: 1.1;
color: grey;
&[data-type='success'] {
color: green;
}
`;
const FieldError = styled(Field.Error)`
display: block;
font-size: 90%;
margin: 0;
margin-top: 4px;
line-height: 1.1;
color: red;
`;
The onChange
validation is debounced by 500ms to avoid firing a network request on every keystroke by specifying the validationDebounceTime
prop:
Styling
The [data-field="valid"]
and [data-field="invalid"]
style hooks determine if the field is invalid or not:
[data-touched]
is applied if the field has been "touched": blurred after being interacted with, or submitted if pressing Enter on an input.
[data-dirty]
is applied if the field's value has been changed from its initial one.
API Reference
FieldRoot
The foundation for building custom-styled fields.
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
disabled | bool | false | Whether the field is disabled. Takes precedence over the disabled prop of the Field.Control component. |
invalid | bool | Determines if the field is forcefully marked as invalid. | |
name | string | The field's name. Takes precedence over the name prop of the Field.Control component. | |
render | union | A function to customize rendering of the component. | |
validate | func | Function to custom-validate the field's value. Return a string or array of strings with error messages if the value is invalid, or null if the value is valid. The function can also return a promise that resolves to a string, array of strings, or null . | |
validationDebounceTime | number | 0 | The debounce time in milliseconds for the validate function in onChange phase. |
validationMode | enum | 'onBlur' | Determines when validation should be triggered. |
FieldLabel
A label for the field's control.
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
render | union | A function to customize rendering of the component. |
FieldDescription
A description message for the field's control.
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
render | union | A function to customize rendering of the component. |
FieldError
Displays error messages for the field's control.
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
forceShow | bool | Determines whether the error message should be shown regardless of the field's client validity. | |
match | enum | Determines whether the error message should be shown when it matches a given property of the field's ValidityState . | |
render | union | A function to customize rendering of the component. |
FieldControl
The field's control element. This is not necessary to use when using a native Base UI input component (Checkbox, Switch, NumberField, Slider, Radio Group etc).
Prop | Type | Default | Description |
---|---|---|---|
className | union | Class names applied to the element or a function that returns them based on the component's state. | |
onValueChange | func | Callback fired when the value changes. Use when controlled. | |
render | union | A function to customize rendering of the component. |
FieldValidity
Render prop component that provides the field's validity state and value to its children.
Prop | Type | Default | Description |
---|