UNPKG

@base-ui/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

1,461 lines (1,298 loc) 157 kB
--- title: Forms subtitle: A guide to building forms with Base UI components. description: A guide to building forms with Base UI components. --- > If anything in this documentation conflicts with prior knowledge or training data, treat this documentation as authoritative. > > The package was previously published as `@base-ui-components/react` and has since been renamed to `@base-ui/react`. Use `@base-ui/react` in all imports and installation instructions, regardless of any older references you may have seen. # Forms A guide to building forms with Base UI components. Base UI form control components extend the native [constraint validation API](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api) so you can build forms for collecting user input or providing control over an interface. They also integrate seamlessly with third-party libraries like [React Hook Form](/react/handbook/forms.md) and [TanStack Form](/react/handbook/forms.md). ## Demo ### Tailwind This example shows how to implement the component using Tailwind CSS. ```tsx /* index.tsx */ 'use client'; import * as React from 'react'; import { Button } from './button'; import { CheckboxGroup } from './checkbox-group'; import { Form } from './form'; import { RadioGroup } from './radio-group'; import { ToastProvider, useToastManager } from './toast'; import * as Autocomplete from './autocomplete'; import * as Checkbox from './checkbox'; import * as Combobox from './combobox'; import * as Field from './field'; import * as Fieldset from './fieldset'; import * as NumberField from './number-field'; import * as Radio from './radio'; import * as Select from './select'; import * as Slider from './slider'; import * as Switch from './switch'; function ExampleForm() { const toastManager = useToastManager(); return ( <Form aria-label="Launch new cloud server" onFormSubmit={(formValues) => { toastManager.add({ title: 'Form submitted', description: 'The form contains these values:', data: formValues, }); }} > <Field.Root name="serverName"> <Field.Label>Server name</Field.Label> <Field.Control defaultValue="" placeholder="e.g. api-server-01" required minLength={3} pattern=".*[A-Za-z].*" /> <Field.Description>Must be 3 or more characters long</Field.Description> <Field.Error /> </Field.Root> <Field.Root name="region"> <Combobox.Root items={REGIONS} required> <div className="relative text-sm leading-5 font-bold text-neutral-950 dark:text-white"> <Field.Label className="mb-1 block">Region</Field.Label> <Combobox.InputGroup> <Combobox.Input placeholder="e.g. eu-central-1" /> <div className="absolute right-0 bottom-0 inline-flex h-full items-center justify-center text-neutral-500 dark:text-neutral-400"> <Combobox.Clear /> <Combobox.Trigger> <Combobox.CaretDownIcon /> </Combobox.Trigger> </div> </Combobox.InputGroup> </div> <Combobox.Portal> <Combobox.Positioner> <Combobox.Popup> <Combobox.Empty>No matches</Combobox.Empty> <Combobox.List> {(region: string) => { return ( <Combobox.Item key={region} value={region}> <Combobox.ItemIndicator> <CheckIcon /> </Combobox.ItemIndicator> <span className="col-start-2">{region}</span> </Combobox.Item> ); }} </Combobox.List> </Combobox.Popup> </Combobox.Positioner> </Combobox.Portal> </Combobox.Root> <Field.Error /> </Field.Root> <Field.Root name="containerImage"> <Autocomplete.Root items={IMAGES} mode="both" itemToStringValue={(itemValue: Image) => itemValue.url} required > <Field.Label>Container image</Field.Label> <Autocomplete.Input placeholder="e.g. docker.io/library/node:latest" /> <Field.Description>Enter a registry URL with optional tags</Field.Description> <Autocomplete.Portal> <Autocomplete.Positioner> <Autocomplete.Popup> <Autocomplete.List> {(image: Image) => { return ( <Autocomplete.Item key={image.url} value={image}> <span>{image.name}</span> <span className="font-mono whitespace-nowrap text-xs opacity-80"> {image.url} </span> </Autocomplete.Item> ); }} </Autocomplete.List> </Autocomplete.Popup> </Autocomplete.Positioner> </Autocomplete.Portal> </Autocomplete.Root> <Field.Error /> </Field.Root> <Field.Root name="serverType"> <Select.Root items={SERVER_TYPES} required> <div className="w-fit space-y-1"> <Select.Label>Server type</Select.Label> <Select.Trigger className="w-48"> <Select.Value /> <Select.Icon> <CaretUpDownIcon /> </Select.Icon> </Select.Trigger> </div> <Select.Portal> <Select.Positioner> <Select.Popup> <Select.ScrollUpArrow /> <Select.List> {SERVER_TYPES.map(({ label, value }) => { return ( <Select.Item key={value} value={value}> <Select.ItemIndicator> <CheckIcon /> </Select.ItemIndicator> <Select.ItemText>{label}</Select.ItemText> </Select.Item> ); })} </Select.List> <Select.ScrollDownArrow /> </Select.Popup> </Select.Positioner> </Select.Portal> </Select.Root> <Field.Error /> </Field.Root> <Field.Root name="numOfInstances"> <NumberField.Root defaultValue={undefined} min={1} max={64} required> <Field.Label>Number of instances</Field.Label> <NumberField.Group> <NumberField.Decrement> <MinusIcon /> </NumberField.Decrement> <NumberField.Input /> <NumberField.Increment> <PlusIcon /> </NumberField.Increment> </NumberField.Group> </NumberField.Root> <Field.Error /> </Field.Root> <Field.Root name="scalingThreshold"> <Fieldset.Root render={ <Slider.Root defaultValue={[0.2, 0.8]} thumbAlignment="edge" min={0} max={1} step={0.01} format={{ style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 0, }} className="w-full gap-y-2" /> } > <Fieldset.Legend>Scaling threshold</Fieldset.Legend> <Slider.Value className="col-start-2 text-end" /> <Slider.Control> <Slider.Track> <Slider.Indicator /> <Slider.Thumb index={0} aria-label="Minimum threshold" /> <Slider.Thumb index={1} aria-label="Maximum threshold" /> </Slider.Track> </Slider.Control> </Fieldset.Root> </Field.Root> <Field.Root name="storageType"> <Fieldset.Root render={<RadioGroup<'ssd' | 'hdd'> className="gap-4" defaultValue="ssd" />}> <Fieldset.Legend className="-mt-px">Storage type</Fieldset.Legend> <Field.Item> <Field.Label> <Radio.Root value="ssd"> <Radio.Indicator /> </Radio.Root> SSD </Field.Label> </Field.Item> <Field.Item> <Field.Label> <Radio.Root value="hdd"> <Radio.Indicator /> </Radio.Root> HDD </Field.Label> </Field.Item> </Fieldset.Root> </Field.Root> <Field.Root name="restartOnFailure"> <Field.Label className="gap-2"> Restart on failure <Switch.Root defaultChecked> <Switch.Thumb /> </Switch.Root> </Field.Label> </Field.Root> <Field.Root name="allowedNetworkProtocols"> <Fieldset.Root render={<CheckboxGroup defaultValue={[]} />}> <Fieldset.Legend className="mb-2">Allowed network protocols</Fieldset.Legend> <div className="flex gap-4"> {['http', 'https', 'ssh'].map((val) => { return ( <Field.Item key={val}> <Field.Label className="uppercase"> <Checkbox.Root value={val}> <Checkbox.Indicator> <CheckIcon /> </Checkbox.Indicator> </Checkbox.Root> {val} </Field.Label> </Field.Item> ); })} </div> </Fieldset.Root> </Field.Root> <Button type="submit" className="mt-3"> Launch server </Button> </Form> ); } export default function App() { return ( <ToastProvider> <ExampleForm /> </ToastProvider> ); } function CaretUpDownIcon(props: React.ComponentProps<'svg'>) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" {...props} style={{ display: 'block', ...props.style }} > <path d="M11 10H5l3 3.5zm0-4H5l3-3.5z" /> </svg> ); } function CheckIcon(props: React.ComponentProps<'svg'>) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" {...props} style={{ display: 'block', ...props.style }} > <path d="m2.5 8.5 4 4 7-9" /> </svg> ); } function PlusIcon(props: React.ComponentProps<'svg'>) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeLinecap="square" strokeLinejoin="round" {...props} style={{ display: 'block', ...props.style }} > <path d="M1.5 8h13M8 14.5v-13" /> </svg> ); } function MinusIcon(props: React.ComponentProps<'svg'>) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeLinecap="square" strokeLinejoin="round" {...props} style={{ display: 'block', ...props.style }} > <path d="M1.5 8h13" /> </svg> ); } function cartesian<T extends string[][]>(...arrays: T): string[][] { return arrays.reduce<string[][]>( (acc, curr) => acc.flatMap((a) => curr.map((b) => [...a, b])), [[]], ); } const REGIONS = cartesian(['us', 'eu', 'ap'], ['central', 'east', 'west'], ['1', '2', '3']).map( (part) => part.join('-'), ); interface Image { url: string; name: string; } /* prettier-ignore */ const IMAGES: Image[] = ['nginx:1.29-alpine', 'node:22-slim', 'postgres:18', 'redis:8.2.2-alpine'].map((name) => ({ url: `docker.io/library/${name}`, name, })); const SERVER_TYPES = [ { label: 'Select server type', value: null }, ...cartesian(['t', 'm'], ['1', '2'], ['small', 'medium', 'large']).map((part) => { const value = part.join('.').replace('.', ''); return { label: value, value }; }), ]; ``` ```tsx /* button.tsx */ import * as React from 'react'; import { Button as BaseButton } from '@base-ui/react/button'; import clsx from 'clsx'; export function Button({ className, ...props }: React.ComponentPropsWithoutRef<'button'>) { return ( <BaseButton type="button" className={clsx( 'flex h-8 items-center justify-center gap-2 rounded-none border border-neutral-950 bg-white px-3 py-0 font-[inherit] text-sm leading-none whitespace-nowrap font-normal text-neutral-950 select-none hover:not-data-disabled:bg-neutral-100 active:not-data-disabled:bg-neutral-200 data-disabled:border-neutral-500 data-disabled:text-neutral-500 disabled:border-neutral-500 disabled:text-neutral-500 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-disabled:bg-neutral-800 dark:active:not-data-disabled:bg-neutral-700 dark:data-disabled:border-neutral-400 dark:data-disabled:text-neutral-400 dark:disabled:border-neutral-400 dark:disabled:text-neutral-400', className, )} {...props} /> ); } ``` ```tsx /* checkbox-group.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { CheckboxGroup as BaseCheckboxGroup } from '@base-ui/react/checkbox-group'; export function CheckboxGroup({ className, ...props }: BaseCheckboxGroup.Props) { return ( <BaseCheckboxGroup className={clsx( 'flex flex-col items-start gap-1 text-neutral-950 dark:text-white', className, )} {...props} /> ); } ``` ```tsx /* form.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Form as BaseForm } from '@base-ui/react/form'; export function Form({ className, ...props }: BaseForm.Props) { return ( <BaseForm className={clsx('flex w-full max-w-3xs flex-col gap-5 sm:max-w-[20rem]', className)} {...props} /> ); } ``` ```tsx /* radio-group.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'; export function RadioGroup<Value>({ className, ...props }: BaseRadioGroup.Props<Value>) { return ( <BaseRadioGroup className={clsx( 'flex w-full flex-row items-start gap-1 text-neutral-950 dark:text-white', className, )} {...props} /> ); } ``` ```tsx /* toast.tsx */ 'use client'; import * as React from 'react'; import { Toast } from '@base-ui/react/toast'; function Toasts() { const { toasts } = Toast.useToastManager(); return toasts.map((toast) => ( <Toast.Root key={toast.id} toast={toast} className="[--gap:0.75rem] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] mr-0 w-full origin-bottom transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] border border-neutral-950 bg-white text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 select-none dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-[''] data-ending-style:opacity-0 data-limited:opacity-0 data-starting-style:transform-[translateY(150%)] [&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:transform-[translateY(150%)] data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))] data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))] data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-150%))] h-(--height) [transition:transform_0.5s_cubic-bezier(0.22,1,0.36,1),opacity_0.5s,height_0.15s]" > <Toast.Content className="h-full overflow-hidden p-3 transition-opacity duration-250"> <Toast.Title className="text-sm font-bold" /> <Toast.Description className="text-sm text-neutral-700 dark:text-neutral-300" /> <div className="mt-2 border border-neutral-950 p-2 text-xs select-text dark:border-white" data-base-ui-swipe-ignore > <pre className="whitespace-pre-wrap">{JSON.stringify(toast.data, null, 2)}</pre> </div> <Toast.Close className="absolute top-3 right-3 flex size-8 items-center justify-center border-0 bg-transparent p-0 text-neutral-950 hover:bg-neutral-100 active:bg-neutral-200 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white dark:text-white dark:hover:bg-neutral-800 dark:active:bg-neutral-700" aria-label="Close" > <XIcon /> </Toast.Close> </Toast.Content> </Toast.Root> )); } export function ToastProvider(props: { children: React.ReactNode }) { return ( <Toast.Provider limit={1}> {props.children} <Toast.Portal> <Toast.Viewport className="fixed z-10 top-auto right-[1rem] bottom-[1rem] mx-auto flex w-[250px] sm:right-[2rem] sm:bottom-[2rem] sm:w-[360px]"> <Toasts /> </Toast.Viewport> </Toast.Portal> </Toast.Provider> ); } export const useToastManager = Toast.useToastManager; function XIcon(props: React.ComponentProps<'svg'>) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeLinecap="square" strokeLinejoin="round" {...props} style={{ display: 'block', ...props.style }} > <path d="m2.5 2.5 11 11m-11 0 11-11" /> </svg> ); } ``` ```tsx /* autocomplete.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Autocomplete } from '@base-ui/react/autocomplete'; export function Root(props: Autocomplete.Root.Props<any>) { return <Autocomplete.Root {...props} />; } export const Input = React.forwardRef<HTMLInputElement, Autocomplete.Input.Props>(function Input( { className, ...props }: Autocomplete.Input.Props, forwardedRef: React.ForwardedRef<HTMLInputElement>, ) { return ( <Autocomplete.Input ref={forwardedRef} className={clsx( 'h-8 w-[16rem] border border-neutral-950 bg-white px-2 text-sm any-pointer-coarse:text-base font-normal text-neutral-950 placeholder:text-neutral-500 focus:outline-2 focus:-outline-offset-1 focus:outline-neutral-950 dark:focus:outline-white md:w-[20rem] dark:border-white dark:bg-neutral-950 dark:text-white dark:placeholder:text-neutral-400', className, )} {...props} /> ); }); export function Portal(props: Autocomplete.Portal.Props) { return <Autocomplete.Portal {...props} />; } export function Positioner({ className, ...props }: Autocomplete.Positioner.Props) { return ( <Autocomplete.Positioner className={clsx('outline-none data-empty:hidden', className)} sideOffset={4} {...props} /> ); } export function Popup({ className, ...props }: Autocomplete.Popup.Props) { return ( <Autocomplete.Popup className={clsx( 'w-(--anchor-width) max-w-(--available-width) border border-neutral-950 bg-white text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none', className, )} {...props} /> ); } export function List({ className, ...props }: Autocomplete.List.Props) { return ( <Autocomplete.List className={clsx( 'max-h-[min(22.5rem,var(--available-height))] overflow-y-auto overscroll-contain py-1 scroll-py-1 outline-0 data-empty:p-0', className, )} {...props} /> ); } export function Item({ className, ...props }: Autocomplete.Item.Props) { return ( <Autocomplete.Item className={clsx( 'flex cursor-default flex-col gap-0.25 py-2 pr-8 pl-2 text-sm leading-4 outline-none select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-white data-highlighted:before:absolute data-highlighted:before:inset-0 data-highlighted:before:z-[-1] data-highlighted:before:bg-neutral-950 dark:data-highlighted:text-neutral-950 dark:data-highlighted:before:bg-white', className, )} {...props} /> ); } ``` ```tsx /* checkbox.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Checkbox } from '@base-ui/react/checkbox'; export function Root({ className, ...props }: Checkbox.Root.Props) { return ( <Checkbox.Root className={clsx( 'flex size-4 shrink-0 items-center justify-center rounded-none border border-neutral-950 bg-white p-0 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-950 dark:focus-visible:outline-white data-checked:bg-neutral-950 data-checked:text-white dark:border-white dark:bg-neutral-950 dark:text-neutral-950 dark:data-checked:bg-white dark:data-checked:text-neutral-950', className, )} {...props} /> ); } export function Indicator({ className, ...props }: Checkbox.Indicator.Props) { return ( <Checkbox.Indicator className={clsx('flex data-unchecked:hidden', className)} {...props} /> ); } ``` ```tsx /* combobox.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Combobox } from '@base-ui/react/combobox'; export function Root(props: Combobox.Root.Props<any, any>) { return <Combobox.Root {...props} />; } export const Input = React.forwardRef<HTMLInputElement, Combobox.Input.Props>(function Input( { className, ...props }: Combobox.Input.Props, forwardedRef: React.ForwardedRef<HTMLInputElement>, ) { return ( <Combobox.Input ref={forwardedRef} className={clsx( 'h-full w-full border-0 bg-white pl-2 text-sm any-pointer-coarse:text-base font-normal text-neutral-950 outline-none placeholder:text-neutral-500 dark:bg-neutral-950 dark:text-white dark:placeholder:text-neutral-400', className, )} {...props} /> ); }); export function InputGroup({ className, ...props }: Combobox.InputGroup.Props) { return ( <Combobox.InputGroup className={clsx( 'relative h-8 w-64 border border-neutral-950 bg-white focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-neutral-950 dark:focus-within:outline-white dark:border-white dark:bg-neutral-950 [&>input]:pr-[2.5rem] has-[.combobox-clear]:[&>input]:pr-[calc(0.5rem+2rem*2)]', className, )} {...props} /> ); } export function Clear({ className, ...props }: Combobox.Clear.Props) { return ( <Combobox.Clear className={clsx( 'combobox-clear flex h-full w-6 items-center justify-center border-0 bg-transparent p-0 text-neutral-950 dark:text-white', className, )} {...props} > <XIcon /> </Combobox.Clear> ); } export function Trigger({ className, ...props }: Combobox.Trigger.Props) { return ( <Combobox.Trigger className={clsx( 'flex h-full w-6 items-center justify-center border-0 bg-transparent p-0 text-neutral-950 dark:text-white', className, )} {...props} /> ); } export function Portal(props: Combobox.Portal.Props) { return <Combobox.Portal {...props} />; } export function Positioner({ className, ...props }: Combobox.Positioner.Props) { return ( <Combobox.Positioner className={clsx('outline-none', className)} sideOffset={4} {...props} /> ); } export function Popup({ className, ...props }: Combobox.Popup.Props) { return ( <Combobox.Popup className={clsx( 'w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) border border-neutral-950 bg-white text-neutral-950 shadow-[0.25rem_0.25rem_0_rgb(0_0_0/12%)] transition-[scale,opacity] duration-100 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none', className, )} {...props} /> ); } export function Empty({ className, children, ...props }: Combobox.Empty.Props) { return ( <Combobox.Empty {...props}> {children ? ( <div className={clsx( 'py-4 pr-4 pl-2 text-sm leading-4 text-neutral-500 dark:text-neutral-400', className, )} > {children} </div> ) : null} </Combobox.Empty> ); } export function List({ className, ...props }: Combobox.List.Props) { return ( <Combobox.List className={clsx( 'outline-0 overflow-y-auto scroll-py-[0.25rem] py-1 overscroll-contain max-h-[min(22.5rem,var(--available-height))] data-empty:p-0', className, )} {...props} /> ); } export function Item({ className, ...props }: Combobox.Item.Props) { return ( <Combobox.Item className={clsx( 'grid cursor-default grid-cols-[1rem_1fr] items-center gap-2 p-2 text-sm leading-4 outline-none select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-white data-highlighted:before:absolute data-highlighted:before:inset-0 data-highlighted:before:z-[-1] data-highlighted:before:bg-neutral-950 dark:data-highlighted:text-neutral-950 dark:data-highlighted:before:bg-white', className, )} {...props} /> ); } export function ItemIndicator({ className, ...props }: Combobox.ItemIndicator.Props) { return <Combobox.ItemIndicator className={clsx('col-start-1', className)} {...props} />; } export function CaretDownIcon(props: React.ComponentProps<'svg'>) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" {...props} style={{ display: 'block', ...props.style }} > <path d="M12 6H4l4 4.5z" /> </svg> ); } function XIcon(props: React.ComponentProps<'svg'>) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeLinecap="square" strokeLinejoin="round" {...props} style={{ display: 'block', ...props.style }} > <path d="m4.5 4.5 7 7m-7 0 7-7" /> </svg> ); } ``` ```tsx /* field.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Field } from '@base-ui/react/field'; export function Root({ className, ...props }: Field.Root.Props) { return <Field.Root className={clsx('flex flex-col items-start gap-1', className)} {...props} />; } export function Label({ className, ...props }: Field.Label.Props) { return ( <Field.Label className={clsx( 'text-sm font-bold text-neutral-950 has-[[role="checkbox"]]:flex has-[[role="checkbox"]]:items-center has-[[role="checkbox"]]:gap-2 has-[[role="checkbox"]]:font-normal has-[[role="radio"]]:flex has-[[role="radio"]]:items-center has-[[role="radio"]]:gap-2 has-[[role="radio"]]:font-normal has-[[role="switch"]]:flex has-[[role="switch"]]:items-center dark:text-white', className, )} {...props} /> ); } export function Description({ className, ...props }: Field.Description.Props) { return ( <Field.Description className={clsx('text-sm text-neutral-600 dark:text-neutral-400', className)} {...props} /> ); } export const Control = React.forwardRef<HTMLInputElement, Field.Control.Props>( function FieldControl( { className, ...props }: Field.Control.Props, forwardedRef: React.ForwardedRef<HTMLInputElement>, ) { return ( <Field.Control ref={forwardedRef} className={clsx( 'h-8 w-full max-w-xs border border-neutral-950 bg-white px-2 text-sm any-pointer-coarse:text-base font-normal text-neutral-950 placeholder:text-neutral-500 focus:outline-2 focus:-outline-offset-1 focus:outline-neutral-950 dark:focus:outline-white dark:border-white dark:bg-neutral-950 dark:text-white dark:placeholder:text-neutral-400', className, )} {...props} /> ); }, ); export function Error({ className, ...props }: Field.Error.Props) { return ( <Field.Error className={clsx('text-sm text-red-700 dark:text-red-400', className)} {...props} /> ); } export function Item(props: Field.Item.Props) { return <Field.Item {...props} />; } ``` ```tsx /* fieldset.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Fieldset } from '@base-ui/react/fieldset'; export function Root(props: Fieldset.Root.Props) { return <Fieldset.Root {...props} />; } export function Legend({ className, ...props }: Fieldset.Legend.Props) { return ( <Fieldset.Legend className={clsx('text-sm font-bold text-neutral-950 dark:text-white', className)} {...props} /> ); } ``` ```tsx /* number-field.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { NumberField } from '@base-ui/react/number-field'; export function Root({ className, ...props }: NumberField.Root.Props) { return ( <NumberField.Root className={clsx('flex flex-col items-start gap-1', className)} {...props} /> ); } export function Group({ className, ...props }: NumberField.Group.Props) { return <NumberField.Group className={clsx('flex h-8', className)} {...props} />; } export function Decrement({ className, ...props }: NumberField.Decrement.Props) { return ( <NumberField.Decrement className={clsx( 'flex h-full w-8 items-center justify-center rounded-none border border-neutral-950 bg-white bg-clip-padding text-neutral-950 outline-0 select-none hover:not-data-disabled:bg-neutral-100 active:not-data-disabled:bg-neutral-200 data-disabled:border-neutral-500 data-disabled:text-neutral-500 focus-visible:z-1 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-disabled:bg-neutral-800 dark:active:not-data-disabled:bg-neutral-700 dark:data-disabled:border-neutral-400 dark:data-disabled:text-neutral-400', className, )} {...props} /> ); } export const Input = React.forwardRef<HTMLInputElement, NumberField.Input.Props>(function Input( { className, ...props }: NumberField.Input.Props, forwardedRef: React.ForwardedRef<HTMLInputElement>, ) { return ( <NumberField.Input ref={forwardedRef} className={clsx( 'h-full w-16 rounded-none border-y border-neutral-950 bg-white px-2 text-sm any-pointer-coarse:text-base font-normal text-neutral-950 tabular-nums focus:z-1 focus:outline-2 focus:-outline-offset-1 focus:outline-neutral-950 dark:focus:outline-white dark:border-white dark:bg-neutral-950 dark:text-white', className, )} {...props} /> ); }); export function Increment({ className, ...props }: NumberField.Increment.Props) { return ( <NumberField.Increment className={clsx( 'flex h-full w-8 items-center justify-center rounded-none border border-neutral-950 bg-white bg-clip-padding text-neutral-950 outline-0 select-none hover:not-data-disabled:bg-neutral-100 active:not-data-disabled:bg-neutral-200 data-disabled:border-neutral-500 data-disabled:text-neutral-500 focus-visible:z-1 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-disabled:bg-neutral-800 dark:active:not-data-disabled:bg-neutral-700 dark:data-disabled:border-neutral-400 dark:data-disabled:text-neutral-400', className, )} {...props} /> ); } ``` ```tsx /* radio.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Radio } from '@base-ui/react/radio'; export function Root({ className, ...props }: Radio.Root.Props) { return ( <Radio.Root className={clsx( 'flex size-4 shrink-0 items-center justify-center rounded-full border border-neutral-950 bg-white p-0 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-950 dark:focus-visible:outline-white data-checked:bg-neutral-950 data-checked:text-white dark:border-white dark:bg-neutral-950 dark:text-neutral-950 dark:data-checked:bg-white dark:data-checked:text-neutral-950', className, )} {...props} /> ); } export function Indicator({ className, ...props }: Radio.Indicator.Props) { return ( <Radio.Indicator className={clsx( 'flex items-center justify-center data-unchecked:hidden before:size-2 before:rounded-full before:bg-current', className, )} {...props} /> ); } ``` ```tsx /* select.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Select } from '@base-ui/react/select'; export function Root(props: Select.Root.Props<any>) { return <Select.Root {...props} />; } export function Label({ className, ...props }: Select.Label.Props) { return ( <Select.Label className={clsx( 'cursor-default text-sm font-bold text-neutral-950 dark:text-white', className, )} {...props} /> ); } export function Trigger({ className, ...props }: Select.Trigger.Props) { return ( <Select.Trigger className={clsx( 'flex h-8 min-w-40 cursor-default items-center justify-between gap-3 border border-neutral-950 bg-white pl-2 pr-1 text-sm font-normal text-neutral-950 select-none hover:not-data-disabled:bg-neutral-100 active:not-data-disabled:bg-neutral-200 data-disabled:border-neutral-500 data-disabled:text-neutral-500 data-popup-open:bg-neutral-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-neutral-950 dark:focus-visible:outline-white dark:border-white dark:bg-neutral-950 dark:text-white dark:hover:not-data-disabled:bg-neutral-800 dark:active:not-data-disabled:bg-neutral-700 dark:data-disabled:border-neutral-400 dark:data-disabled:text-neutral-400 dark:data-popup-open:bg-neutral-800', className, )} {...props} /> ); } export function Value({ className, ...props }: Select.Value.Props) { return ( <Select.Value className={clsx( 'data-placeholder:text-neutral-500 dark:data-placeholder:text-neutral-400', className, )} {...props} /> ); } export function Icon(props: Select.Icon.Props) { return <Select.Icon {...props} />; } export function Portal(props: Select.Portal.Props) { return <Select.Portal {...props} />; } export function Positioner({ className, ...props }: Select.Positioner.Props) { return ( <Select.Positioner className={clsx('outline-none select-none z-10', className)} sideOffset={4} {...props} /> ); } export function Popup({ className, ...props }: Select.Popup.Props) { return ( <Select.Popup className={clsx( 'group min-w-(--anchor-width) origin-(--transform-origin) border border-neutral-950 bg-white bg-clip-padding text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 transition-[scale,opacity] duration-100 ease-out data-[side=none]:min-w-[calc(var(--anchor-width)+1.75rem)] data-[side=none]:translate-y-px data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-[side=none]:data-ending-style:transition-none data-starting-style:scale-[0.98] data-starting-style:opacity-0 data-[side=none]:data-starting-style:scale-100 data-[side=none]:data-starting-style:opacity-100 data-[side=none]:data-starting-style:transition-none dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none', className, )} {...props} /> ); } export function ScrollUpArrow({ className, ...props }: Select.ScrollUpArrow.Props) { return ( <Select.ScrollUpArrow className={clsx( "top-0 z-1 flex h-4 w-full cursor-default items-center justify-center bg-white text-center text-xs before:absolute data-[side=none]:before:-top-full before:left-0 before:h-full before:w-full before:content-[''] dark:bg-neutral-950", className, )} {...props} /> ); } export function ScrollDownArrow({ className, ...props }: Select.ScrollDownArrow.Props) { return ( <Select.ScrollDownArrow className={clsx( "bottom-0 z-1 flex h-4 w-full cursor-default items-center justify-center bg-white text-center text-xs before:absolute before:left-0 before:h-full before:w-full before:content-[''] data-[side=none]:before:-bottom-full dark:bg-neutral-950", className, )} {...props} /> ); } export function List({ className, ...props }: Select.List.Props) { return ( <Select.List className={clsx( 'relative max-h-(--available-height) overflow-y-auto py-1 scroll-py-6', className, )} {...props} /> ); } export function Item({ className, ...props }: Select.Item.Props) { return ( <Select.Item className={clsx( 'grid cursor-default grid-cols-[1rem_1fr] items-center gap-2 py-1.5 pr-4 pl-2.5 text-sm outline-none select-none group-data-[side=none]:pr-12 data-highlighted:bg-neutral-950 data-highlighted:text-white dark:data-highlighted:bg-white dark:data-highlighted:text-neutral-950', className, )} {...props} /> ); } export function ItemIndicator({ className, ...props }: Select.ItemIndicator.Props) { return <Select.ItemIndicator className={clsx('col-start-1', className)} {...props} />; } export function ItemText({ className, ...props }: Select.ItemText.Props) { return <Select.ItemText className={clsx('col-start-2', className)} {...props} />; } ``` ```tsx /* slider.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Slider } from '@base-ui/react/slider'; export function Root({ className, ...props }: Slider.Root.Props<any>) { return <Slider.Root className={clsx('grid grid-cols-2', className)} {...props} />; } export function Value({ className, ...props }: Slider.Value.Props) { return ( <Slider.Value className={clsx('text-sm font-normal text-neutral-950 dark:text-white', className)} {...props} /> ); } export function Control({ className, ...props }: Slider.Control.Props) { return ( <Slider.Control className={clsx('flex col-span-2 touch-none items-center py-3 select-none', className)} {...props} /> ); } export function Track({ className, ...props }: Slider.Track.Props) { return ( <Slider.Track className={clsx('h-1 w-full bg-neutral-200 select-none dark:bg-neutral-800', className)} {...props} /> ); } export function Indicator({ className, ...props }: Slider.Indicator.Props) { return ( <Slider.Indicator className={clsx('bg-neutral-950 select-none dark:bg-white', className)} {...props} /> ); } export function Thumb({ className, ...props }: Slider.Thumb.Props) { return ( <Slider.Thumb className={clsx( 'size-4 border border-neutral-950 bg-white select-none has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-neutral-950 dark:has-[:focus-visible]:outline-white dark:border-white dark:bg-neutral-950', className, )} {...props} /> ); } ``` ```tsx /* switch.tsx */ import * as React from 'react'; import clsx from 'clsx'; import { Switch } from '@base-ui/react/switch'; export function Root({ className, ...props }: Switch.Root.Props) { return ( <Switch.Root className={clsx( 'flex h-5 w-9 shrink-0 border border-neutral-950 bg-white p-0.5 transition-colors duration-150 ease-[ease] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-950 dark:focus-visible:outline-white data-checked:bg-neutral-950 dark:border-white dark:bg-neutral-950 dark:data-checked:bg-white', className, )} {...props} /> ); } export function Thumb({ className, ...props }: Switch.Thumb.Props) { return ( <Switch.Thumb className={clsx( 'size-3.5 bg-neutral-950 transition-[translate,background-color] duration-150 ease-[ease] data-checked:translate-x-4 data-checked:bg-white dark:bg-white dark:data-checked:bg-neutral-950', className, )} {...props} /> ); } ``` ## Naming form controls Form controls must have an accessible name in order to be recognized by assistive technologies. Use the label strategy below for each control type. ### Input controls Use `<Field.Label>` or a native `<label>` to label the following controls: - `Input` - `NumberField` - `OTPField` - `Autocomplete` - `Combobox` (input outside popup) - `Checkbox` - `Radio` - `Switch` You can implicitly label `<Checkbox>`, `<Radio>` and `<Switch>` components by enclosing them with `<Field.Label>`: ```tsx title="Implicitly labeling a switch" import { Field } from '@base-ui/react/field'; import { Switch } from '@base-ui/react/switch'; <Field.Root> <Field.Label> <Switch.Root /> Developer mode </Field.Label> <Field.Description>Enables extra tools for web developers</Field.Description> </Field.Root>; ``` ### Trigger-based controls - `Combobox` (input inside popup): use `<Combobox.Label>`. - `Select`: use `<Select.Label>`. - `Slider`: use `<Slider.Label>`. For multi-thumb sliders, also add an `aria-label` on each `<Slider.Thumb>` to distinguish the thumbs. ### Fallback If no visible label is rendered, provide `aria-label` on the actual form control. ### Describing the control `<Field.Description>` automatically assigns an accessible description: ```tsx title="Labeling select and slider" import { Form } from '@base-ui/react/form'; import { Field } from '@base-ui/react/field'; import { Select } from '@base-ui/react/select'; import { Slider } from '@base-ui/react/slider'; <Form> <Field.Root> <Select.Root> <Select.Label>Time zone</Select.Label> <Select.Trigger /> </Select.Root> <Field.Description>Used for notifications and reminders</Field.Description> </Field.Root> <Field.Root> <Slider.Root defaultValue={50}> <Slider.Label>Zoom level</Slider.Label> <Field.Description>Adjust the size of the user interface</Field.Description> <Slider.Control> <Slider.Track> <Slider.Thumb /> </Slider.Track> </Slider.Control> </Slider.Root> </Field.Root> </Form>; ``` ### Labeling control groups Compose `<Fieldset>` when a single label applies to multiple controls, such as a range slider with multiple thumbs or a section that combines several inputs. For checkbox and radio groups, keep the group label in `<Fieldset.Legend>` and wrap each option with `<Field.Item>`: ```tsx title="Composing range slider and radio group with fieldset" import { Form } from '@base-ui/react/form'; import { Field } from '@base-ui/react/field'; import { Fieldset } from '@base-ui/react/fieldset'; import { Radio } from '@base-ui/react/radio'; import { RadioGroup } from '@base-ui/react/radio-group'; import { Slider } from '@base-ui/react/slider'; <Form> <Field.Root> {/* @highlight-start */} <Fieldset.Root render={<Slider.Root />}> <Fieldset.Legend>Price range</Fieldset.Legend> {/* @highlight-end */} <Slider.Control> <Slider.Track> <Slider.Thumb aria-label="Minimum price" /> <Slider.Thumb aria-label="Maximum price" /> </Slider.Track> </Slider.Control> {/* @highlight */} </Fieldset.Root> </Field.Root> <Field.Root> {/* @highlight-start */} <Fieldset.Root render={<RadioGroup />}> <Fieldset.Legend>Storage type</Fieldset.Legend> {/* @highlight-end */} <Radio.Root value="ssd" /> <Radio.Root value="hdd" /> {/* @highlight */} </Fieldset.Root> </Field.Root> </Form>; ``` `<Field.Item>` should enclose each checkbox or radio option so every control has its own label and description: ```tsx title="Explicitly labeling checkboxes in a checkbox group" import { Form } from '@base-ui/react/form'; import { Field } from '@base-ui/react/field'; import { Fieldset } from '@base-ui/react/fieldset'; import { Checkbox } from '@base-ui/react/checkbox'; import { CheckboxGroup } from '@base-ui/react/checkbox-group'; <Field.Root> <Fieldset.Root render={<CheckboxGroup />}> <Fieldset.Legend>Backup schedule</Fieldset.Legend> {/* @highlight */} <Field.Item> <Checkbox.Root value="daily" /> <Field.Label>Daily</Field.Label> <Field.Description>Daily at 00:00</Field.Description> {/* @highlight-start */} </Field.Item> <Field.Item> {/* @highlight-end */} <Checkbox.Root value="monthly" /> <Field.Label>Monthly</Field.Label> <Field.Description>On the 5th of every month at 23:59</Field.Description> {/* @highlight */} </Field.Item> </Fieldset.Root> </Field.Root>; ``` ## Building form fields Pass the `name` prop to `<Field.Root>` to include the wrapped control's value when a parent form is submitted: ```tsx title="Assigning field name to combobox" import { Form } from '@base-ui/react/form'; import { Field } from '@base-ui/react/field'; import { Combobox } from '@base-ui/react/combobox'; <Form> {/* @highlight-start */} {/* @highlight-text "name" */} <Field.Root name="country"> {/* @highlight-end */} <Field.Label>Country of residence</Field.Label> <Combobox.Root /> </Field.Root> </Form>; ``` ## Submitting data You can take over form submission using the native `onSubmit`, or custom `onFormSubmit` props: ```tsx title="Native submission using onSubmit" import { Form } from '@base-ui/react/form'; <Form // @highlight-start onSubmit={async (event) => { // Prevent the browser's default full-page refresh event.preventDefault(); // Create a FormData object const formData = new FormData(event.currentTarget); // Send the FormData instance in a fetch request // @highlight-end await fetch('https://api.example.com', { method: 'POST', body: formData, }); }} />; ``` When using `onFormSubmit`, you receive form values as a JavaScript object, with `eventDetails` provided as a second argument. Additionally, `preventDefault()` is automatically called on the native submit event: ```tsx title="Submission using onFormSubmit" import { Form } from '@base-ui/react/form'; <Form // @highlight-start onFormSubmit={async (formValues) => { const payload = { product_id: formValues.id, order_quantity: formValues.quantity, }; await fetch('https://api.example.com', { // @highlight-end method: 'POST', body: JSON.stringify(payload), }); }} />; ``` ## Constraint validation Base UI form components support native HTML validation attributes for many validation rules: - `required` specifies a required field. - `minLength` and `maxLength` specify a valid length for text fields. - `pattern` specifies a regular expression that the field value must match. - `step` specifies an increment that numeric field values must be an integral multiple of. ```tsx title="Defining constraint validation on a text field" import { Field } from '@base-ui/react/field'; <Field.Root name="website"> <Field.Control type="url" required pattern="https?://.*" /> <Field.Error /> </Field.Root>; ``` Base UI form components use a hidden input to participate in native form submission and validation. To anchor the hidden input near a control so the native validation bubble points to the correct area, ensure the component has been given a `name`, and wrap controls in a relatively positioned container for best results. ```tsx title="Positioning hidden inputs" import { Field } from '@base-ui/react/field'; import { Select } from '@base-ui/react/select'; <Field.Root name="apple"> <Select.Root> <Select.Label>Apple</Select.Label> <div className="relative"> <Select.Trigger /> </div> </Select.Root> </Field.Root>; ``` ## Custom validation You can add custom validation logic by passing a synchronous or asynchronous validation function to the `validate` prop, which runs after native validations have passed. Use the `validationMode` prop to configure when validation is performed: - `onSubmit` (default) validates all fields when the containing `<Form>` is submitted, afterwards invalid fields revalidate when their value changes. - `onBlur` validates the field when focus moves away. - `onChange` validates the field when the value changes, for example, after each keypress in a text field or when a checkbox is checked or unchecked. `validationDebounceTime` can be used to debounce the function in use cases such as asynchronous requests or text fields that validate `onChange`. ```tsx title="Text input using custom asynchronous validation" import { Field } from '@base-ui/react/field'; <Field.Root name="username" {/* @highlight-start */} validationMode="onChange" validationDebounceTime={300} validate={async (value) => { // @highlight-end if (value === 'admin') { /* return an error message when invalid */ return 'Reserved for system use.'; } const result = await fetch( {/* prettier-ignore */}, /* check the availability of a username from an external API */