Rules for input components — components that can be attached to a <Form>. An input component is anything that calls useFieldProps and accepts a name prop, e.g. TextInput, Select, ComboBox, Checkbox, RadioGroup, Switch, Slider, DatePicker, FileInput, ListBox, Picker.
All input components live under src/components/fields/{ComponentName}/.
- Extend
FieldBaseProps<Value>(the control’s accepted model value, including supported empty values) fromsrc/shared/index(it already includesFormBaseProps+FieldCoreProps). Never redeclare shared field props (label,name,isRequired,isDisabled,description,rules, …). - Name the props interface
Cube{ComponentName}Props. - Mix in
FieldLayoutStyleProps(form/field-layout.ts, tasty's position and dimension props pluscontrolWidth) and no other style-prop interface. An input's style props place and size its outer element (§3), andcontrolWidththe control; everything else goes throughstylesand the part props (inputStyles,triggerStyles, …). - Never give the control, or a part of it, a prop named like one of
RESERVED_FIELD_PROPS(form/field-chrome.ts):wrapWithFieldreads those names off the input's props, so the chrome would render it too. The control's own version takes its part's prefix (triggerDescription,inputStyles) or a name the chrome does not own:tooltipis the control's tooltip, as onSelect,Picker,ButtonandItem, and the label's badge islabelTooltip.SHARED_FIELD_PROPS(id,isDisabled,isRequired,isInvalid,isValid) are read by both, by design. Until the legacytooltipwarning goes,wrapWithFieldalso readstooltipto warn, so an input whose control takestooltippassestooltip: undefinedtowrapWithField, asSelect,Picker,FilterPicker,ColorPicker,Checkbox,Switchand the text inputs (TextInputBase) do. field prop names inform/wrapper.test.tsxchecks that the wrapper reads nothing else buttooltipand that every input renders the reserved texts in its field, never in its control.
- Use
isInvalidandisValidbooleans. Never add or readvalidationState. validationStateis a deprecated compatibility prop. It is normalized intoisInvalid/isValidbyuseFieldPropsand stripped from the resolved props, so component bodies never see it.- Precedence (implemented once in
resolveValidationProps):- explicit
isInvalid/isValid - explicit
validationState(logs a deprecation warning in dev) - form-derived state (field errors, or
showValid+ a valid field status)
- explicit
isInvalidwins overisValidwhen both resolve totrue.- Pass
isInvalidstraight through to React Aria hooks — they accept it natively and have deprecatedvalidationStatethemselves.
useFieldProps is the single entry point. It already applies useProviderProps and useFormProps internally, so a component body needs one call:
function MyField(props: CubeMyFieldProps, ref) {
props = useFieldProps(props, {
defaultValidationTrigger: 'onBlur',
valuePropsMapper: ({ value, onChange }) => ({ value: value ?? '', onChange }),
});
// props.id is set, props.labelProps.for matches it,
// props.isInvalid / props.isValid are resolved booleans.
}Rules:
- Do not call
useProviderPropsoruseFormPropsmanually in an input component. - Do not generate your own
useId()and do not setlabelProps.for—useFieldPropsowns both. - Do not call
useFielddirectly. It is internal touseFieldPropsand to the legacy<Field>. - If a component must read the raw props before context merging (e.g.
Checkboxdistinguishing its own props from group context), captureoriginalProps = propsbefore theuseFieldPropscall.
defaultValidationTrigger—'onChange'for toggles and selection controls (Checkbox,Switch,Radio,Select,ListBox,FileInput),'onBlur'for free-text controls (TextInput,TextArea,NumberInput,DateInput).valuePropsMapper— maps the form value onto the component's own value API. Required whenever the component does not usevalue/onChangeverbatim (selectedKey/onSelectionChange,isSelected/onChange,selectedKeys, …).unsafe__isDisabled— opts out of field wiring for the lifetime of the mount. Only for components that can be nested inside a group that owns the form connection (CheckboxinsideCheckboxGroup). The value must be stable across renders.showsValidateErrors— the control shows the errors of itsvalidateunder itself (TextInput,TextArea,PasswordInput,NumberInput), so avalidateasks for the field chrome without a label.
useFieldProps calls the same hooks in every mode — including useField, the legacy backend's adapter — and the mode only decides which props come back:
| Mode | Condition | What comes back |
|---|---|---|
| Legacy bound | name is set, not inside the deprecated <Field>, not unsafe__isDisabled |
The field's value/onChange/onBlur and validation state merged over the props, with an incremental id derived from the form name and the field name (email, email_1, settings_email, …) |
| Modern bound | A modern controller plus name, not inside <Field> or disabled |
Per-field value/error/status subscription, commit-time registration, and a name-prefixed React useId() id; no creator or unrelated-field subscription |
| Standalone | no name |
The caller's own value/onChange untouched, plus an id from React's useId() and matching label props; useField runs inert and registers nothing |
Inside <Field> / Form.Item |
useInsideLegacyField() |
The props as given: the wrapper already did the binding |
| Disabled | unsafe__isDisabled |
The props as given |
For the legacy backend, because every hook runs every time, an input may gain or lose name, or receive a form later, without a hook-order error. Gaining a name, or a form, binds like a first mount: a new field with the field-level defaultValue as value and baseline. The id is derived from the surrounding form's name prefix and the field name, so it changes with name (and with the prefix), not when a form arrives. A value typed while the input was standalone is not carried into the field, and React Aria logs its development warning about switching between uncontrolled and controlled — both expected. Losing the name releases the field. A form prop that changes registers the field with the new form and the old form keeps it while the input is mounted (legacy contract, row 4); losing the form prop while keeping the name leaves the field in that form too, and a form that comes back reuses it; unmounting releases the name from every form it was bound to. Do not add a hook to useFieldProps behind a condition and do not return before the hooks.
When a props interface extends a react-aria props type that carries the DOM form?: string attribute (text fields, number field, checkbox, switch, radio group, slider, date and time fields), keep the kit's form (the instance) on top: add 'form' to the Omit<Aria…Props, …> the interface already uses, or use Omit<Aria…Props, 'form'> before extending the Aria type directly. Adobe's consolidated types are checked in-repo, including with preserveSymlinks. The consumer-facing fixture typecheck/consumer/form-instance.test-d.tsx, compiled against dist/ by pnpm test:types:consumer, renders every form-attachable input with form={instance} and fails when the DOM attribute wins.
Components must apply the current id from props to the element the label points at. React Aria hooks seed their ids once (useId(props.id)), so a component that hands props.id only to the hook keeps the mount-time id after a rebind; TextInput gets this for free from useTextField, NumberInput merges { id: props.id } into inputProps and SliderBase sets id after spreading groupProps.
The form itself comes from the form prop when it is set, and from FormContext otherwise. That makes <TextInput name="email" form={form} /> a supported way to link an input to a form it is not nested in, and to override the surrounding form. Keep form in the props of every form-attachable component and always pass the whole props object to useFieldProps so this keeps working.
Form instances are branded (see Form/backend.ts). Form.useController() creates the modern controller; named inputs and custom controls using useFieldProps bind through use-field-binding.ts. The legacy adapter runs inert on this path. Modern registration and option updates happen in layout effects, never during render. A changed name/controller releases the old registration immediately; values remain retained by default. preserve={false} removes an inactive value after one microtask so Strict Mode effect replay can reconnect safely; reset can restore its baseline. Controller values/defaults, including explicit undefined and null, precede field defaults. Every duplicate input has its own stable React id and registration token. Only value, errors, or status changes rerender the input; metadata belongs in Form.useSelector or Form.Subscribe.
Form.Item remains legacy-only: modern custom controls use useFieldProps and wrapWithField directly. Do not call a controller getter during render to implement a reactive input. Modern roots expose their controller through useFormProps so named inputs cannot silently bind to an outer legacy form. Standalone inputs and explicit form={undefined} remain detached. Legacy roots and FormScopeMask mask modern context. The shared hook strips the controller from resolved input props before passing them to React Aria, whose form attribute is a DOM id. A legacy instance survives the hook, because legacy custom controls read it, so see DOM conventions before handing resolved props to a React Aria hook. Modern rules run after field events or explicit controller validation, with AbortSignal cancellation and errorPolicy ordering. Forward deps for external validator inputs and dependsOn for declared field dependencies; rulesKey opts into manual function versioning and skips source comparison; declarative constraints are always compared. deps alone does not disable source comparison. Validator reads cancel in-flight work but only declared dependsOn paths schedule dependent revalidation. Equivalent inline rules must not restart pending work. Root and action helpers use selector subscriptions; never read mutable legacy flags from a modern controller.
FormScopeMask (exported) scopes the inputs below it to a new form context: Radio/Checkbox groups render it around their options so a nested name never registers as an independent field. The groups also render a <Provider isDisabled isReadOnly> with their own resolved state, because each option is a field that reads the provider before the form context: without it, an enclosing disabled form beats a group's isDisabled={false} on every option. <Provider> skips undefined, so a group that sets nothing keeps the form's state. A wrapper that overrides FormContext for the same purpose must render FormScopeMask instead of a bare FormContext.Provider, because presentation props (labelPosition, idPrefix, …) also travel through a separate context that only the mask resets.
useFormProps stays a public export because wrappers outside the UI Kit call it to read the form context and then hand adjusted props to a nested input. Since it merges as { ...presentationContext, ...FormContext, ...props } (the two contexts carry the same presentation values under a legacy root), and useFieldProps applies it again, any prop the wrapper sets explicitly wins — but a deleted key falls back to the context value, and so does a provider state prop (isDisabled, isReadOnly, isRequired, isInvalid, isValid) set to undefined, which useProviderProps treats as unset. To detach a nested input from the form, pass form={undefined} (or null) explicitly, or omit name; destructuring form away is not enough.
- Wrap the control with
wrapWithField(control, props)as the return value. It renders aFieldWrapper(label, description, message, necessity indicator) when the field has something to show, and the bare control otherwise (rendersFieldChromeinform/wrapper.tsx). An explicitisBaredecides first:trueomits field layout and text slots, preserving any supplied label as a visually hidden accessible label with the merged label id/for, andfalserenders the chrome. Bare labels contain no interactive decorations. Unset, a non-emptylabelrenders the chrome, as does the deprecatedforceField. useFieldPropsasks for the chrome withisBare={false}(withFieldChromeinform/field-chrome.ts, which keeps an explicitisBare) on a field bound to a form, whose validation errors need a place, or whose caller passesdescription,errorMessage,message,fieldStyles,fieldPropsorisHidden(asksForFieldChrome).SearchInputandSearchComboBox, which skipuseFieldProps, ask the same way.TagInputasks withwithFieldChromeitself for its refusals.TextInput,TextArea,PasswordInputandNumberInputpassshowsValidateErrors, so avalidateasks too; other controls do not show its errors.- Never decide wrapper presence from message content. A prop counts whatever it holds,
undefined,nulland''too, so a message that comes and goes never moves the control in or out of the wrapper: React remounts a re-parented control, and it loses focus and its draft mid-keystroke. Read the caller's own props, since a form binding adds props of its own.labelis the one exception: it counts by content, so an empty or undefinedlabelis no label. A child of the legacy<Field>counts as bound when the<Field>gives it aname; its message props come and go with the error, so they decide nothing. A nameless legacyForm.Itemalready owns its child's chrome, so its child stays bare. Parent-owned controls (nested lists, date/time segments, table checkboxes and compact selectors) passisBareexplicitly, after any spread of the parent's resolved props, which can carryisBare={false}; existing grouped Checkbox/Radio rendering paths stay inline. errorMessagetakes precedence when it is defined, including explicit null/false/empty suppression; otherwise fall back to deprecatedmessage. Keepdescriptionindependent. Do not change the legacy or modern binding adapters to implement presentation precedence.- The field's
refis its control's: attach it, or the object ref behind its imperative handle, to the control's own root element, so it is the same element with or without the chrome and nevernullon a bare field. The chrome takes no ref.labelRefreaches the label, visible or visually hidden, andfieldProps={{ ref }}the wrapper. The deprecatedwrapWithField(control, ref, props)ignores itsref. wrapWithFielddestructures the wrapper props itself. Pass the fullpropsobject; do not hand-pick keys and do not blank outform.- Shorthands are merged by the wrapper:
fieldStylesoverfieldProps.styles(as style layers, so anullreset survives) andlabelStyles→labelProps.styles. Do not merge them in the component.fieldProps.modsadds to the mods the wrapper computes. - Route the style props with
splitFieldLayout(props, options)(form/field-layout.ts), called on the props passed towrapWithField(afteruseFieldPropsorwithFieldChrome). Render the control with thestylesit returns and pass{ ...props, ...field }towrapWithField. Bare, the style props join the control'sstyles. With the chrome they go to the wrapper, overfieldStyles, and a wrapper they size getsfill-width/fill-height: its input area becomes a one-column grid, so the control stretches unless its own CSS fixes or caps its size.fieldStylesnever sets them.stylesalways stays on the control, andcontrolWidth(declared onFieldLayoutStyleProps) sets its width overstyles, with or without the chrome. Options:hasChrome: falseon a path that never wraps (a groupedCheckbox),fillHeightOnFlexfor a control that grows with its flex line when bare (lists, text areas),defaultStylesfor the control's own styles understyles, andoverStylesfor a prop that styles the same element overstyles(wrapperStyleson the date pickers), socontrolWidthstill wins over it. The field layout style props matrix inform/wrapper.test.tsxruns over every input. - The
idbelongs on the interactive control, never on the field wrapper. - Put a React Aria hook's group props (
radioGroupProps,groupProps) on the control's own element, not infieldProps: a field rendered bare has no wrapper, and the role, name, keyboard and focus handlers go with it.RadioGroup,CheckboxGroup,ColorSwatchGroupandSliderBasedo this.
TextInputBase, DateInputBase and similar *Base components render markup only. They must not call useFieldProps, useFormProps, useProviderProps or useField. Form wiring belongs to the public component that renders the base (TextInput, TextArea, PasswordInput, NumberInput, CommandTextArea). Wiring a field in both the wrapper and the base registers the field twice.
SearchInput and SearchComboBox look like inputs but are not form-attachable. They drive local UI filtering, so they never register with a form. They call useProviderProps + useValidationProps instead of useFieldProps, which gives them the shared validation props and provider defaults without registering a field. Do not add useFieldProps to them.
Import from src/components/form/validation:
getValidationMods({ isInvalid, isValid })→{ invalid, valid }for tastymods.getValidationTheme(theme, { isInvalid, isValid })→'danger'/'success'/ the passed theme, for trigger buttons and items.<ValidationIndicator isInvalid isValid isLoading />— renders thedata-element="State"suffix block (validation icon or loading spinner). UsehasValidationIndicator()to decide whether adata-element="Suffix"container is needed at all.resolveValidationProps(props)/useValidationProps(props)— normalization. OnlyuseFieldPropsand components outside the field pipeline should need these.
Do not hand-write { invalid: …, valid: … } mods or validation-icon markup.
Every input component renders both states. When you add a new one, copy the reference that matches its shape instead of inventing a new treatment. Mirror the danger token the component already uses with its success counterpart (#danger → #success, #danger-text.50 → #success-text.50) rather than picking a new color.
- Input chrome —
valid/invalidborder onINPUT_WRAPPER_STYLESplus a<ValidationIndicator>suffix. Reference:TextInputBase. Used byTextInput,TextArea,PasswordInput,NumberInput,CommandTextArea,SearchInput,SearchComboBox,ComboBox,TagInput, and — throughDateInputBase—DateInput,TimeInput,DatePicker,DateRangePicker,DateRangeSeparatedPicker.FileInputreuses the same border tokens and indicator on itsAction-based control. - Trigger button —
getValidationThemefor the danger theme plusgetValidationIconappended to the trigger'ssuffix. Reference:Select. Used byPickerandFilterPicker. - Listbox border —
valid/invalidborder on the list container. Reference:ListBox. Used byFilterListBox.ListBoxitems additionally map the state onto an item theme viagetValidationTheme(…, { includeValid: true }). - Control fill and border —
getValidationModson the control element with matchingfill/border/colorentries. Reference:Checkbox. Used bySwitchandRadio.CheckboxGroupandRadioGroupown no chrome of their own; they publish the state throughFormContextand the items render it. Button-shaped radios go throughgetValidationTheme('default', …, { includeValid: true }). - Track and thumb fill —
SliderandRangeSlider. The state is resolved insideSliderBase, so it reachesSliderThumb/SliderTrackthroughSliderBaseChildArguments, not through the outer props. - Delegated to inner inputs —
TextInputMapperforwardsisInvalid/isValidto itsKeyComponentandValueComponent.
HueSlider is not form-attachable (it never calls useFieldProps) and has no validation state.
- Set
data-input-typeon the interactive element (textinput,checkbox,datetimeinput, …). - Default the
qaprop of the control to the component name:qa={qa || 'Checkbox'}. - Use
ComponentBaseProps(orComponentBasePropsWithoutChildren) for a field that owns its root, and usefilterComponentBasePropsfor generic DOM forwarding. KeepclassNameandstylefor integrations. Declarethemeortitleseparately when the component gives the prop a defined meaning. - Keep the kit's
form(the form instance) out of every React Aria hook that forwardsformto a native element as a DOM form id:useTextField,useCheckbox/useCheckboxGroupItem,useSwitch,useCheckboxGroup,useRadioGroup(both group hooks hand it to every item,ColorSwatchGroupincluded) anduseSelect(forHiddenSelect). The date and time field hooks forward it too, to hidden inputs the kit does not render today, anduseSliderThumbforwards its ownformto the range input the kit does render: the instance stays off it only becauseSliderandRangeSliderbuild eachSliderThumbfrom an explicit prop list withoutform. Destructureformout of the props the hook receives, or passform: undefined. Otherwise a legacy instance renders asform="[object Object]", which detaches the input from its<form>, so Enter no longer submits it.explicit-form-prop.test.tsxchecks every form-attachable input for a leakedformattribute under both backends, andfield-accessibility.test.tsxchecks that a screen reader hears each one's description and error and thataria-labelalone names it. Both run overFORM_ATTACHABLE_INPUTSinsrc/test/form-attachable-inputs.tsx: add new inputs there, and a separate case for controls that only mount in a popover. React Aria before 3.48 also had aFocusScopefailure for a lone unchecked radio in a dialog form; the upgraded dependency includes that upstream fix. - Take the control's styles from
splitFieldLayout(§3), notextractStyles. - With
autoFocus, also setdata-autofocuson the element that takes focus, or on a wrapper whose first tabbable element does, and only then, so a consumer's own mark survives. This holds for actions (useAction) andCommandMenutoo. AhideOnClosedialog mounts its content once, hidden, and focuses the last marked element on every open;DialogContainer.test.tsxcovers each kind of marked control.
Every input component ships:
ComponentName.tsx— implementation following the rules aboveindex.tsx— re-export, wired into the category barrel andsrc/index.tsComponentName.stories.tsx— shared argTypes fromsrc/stories/FormFieldArgs.ts(useVALIDATION_ARGSrather than re-declaringisInvalid/isValid); size the whole field with style props (width) in meta args, and the control alone withcontrolWidth; existingfieldStylessizing still works; export a singleValidationstory rendering the valid and invalid case together (see storybook.md)ComponentName.docs.mdx— linksSupports all [Field properties](/docs/getting-started-field-properties--docs)instead of duplicating field props (see documentation.md)ComponentName.test.tsx— usesrenderWithFormfor form integration andrenderWithRoototherwise (see tests.md)
After changing an input component's API, run pnpm audit-docs --component=ComponentName.
field={form.field(path, options)} carries controller/path/options into the shared hook, including nested tuple paths. Construction is pure; registration stays in the commit effect. useFieldProps resolves descriptor identity/options and strips field, deps, and dependsOn before DOM forwarding. Its return type omits them too, and drops the modern controller from form's type, since the controller is stripped as well: a form declared as FormController<…> comes back undefined, and CubeFormInstance | FormController<…> comes back CubeFormInstance | undefined. A form typed any, as in FieldBaseProps, stays any and is not checked. So a custom control that needs the controller declares form in an interface that extends FieldBaseProps (an intersection FieldBaseProps & { form?: … } is any again) and reads it from its own props (typecheck/consumer/use-field-props-result.test-d.tsx). Keep the base value API correct (booleans for toggles, numbers for numeric controls, selection keys for selectors). The descriptor generic describes accepted model values, not only emitted changes: include null | undefined when the binding handles missing values. Normalize only for display; never write the fallback into the form on mount. Add nullable-model consumer fixtures and preserve nulls through submission/reset in runtime tests. Do not add another registration path or read controller getters during render. Existing name bindings and legacy rule props keep their compatibility contract.
Modern inputs accept name={readonlyTuple} without a descriptor. The shared binding retains the path for store access and converts it to an escaped string for DOM names/ids; do not coerce arrays with String() or .join() in a component. useFieldProps returns a string name, including standalone/masked inputs, so custom controls can safely forward resolved props. Native React Aria name types must not narrow public field names back to string: omit name alongside form, or explicitly redeclare the kit's name where the Aria interface is extended directly. Legacy bindings accept string names only.