react-formbridge is intentionally styling-framework agnostic. The form runtime owns value, validation, visibility, and submit lifecycle. Your app stays free to style that runtime with CSS Modules, styled-components, Tailwind-style utilities, inline objects, React Native StyleSheet, NativeWind-friendly wrappers, or an in-house design system.
- Put field-owned behavior metadata in the schema when it should travel with the field everywhere the schema is reused
- Put styling in
useFormBridge(schema, { globalDefaults })when one screen, one route, or one product area needs a shared visual language - Put styling directly on
<fields.name classNames={...} styles={...} />when a single field needs a local exception, and let the generated field type decide which override props are available - Reach for
form.fieldController(name)when a built-in field needs fully custom chrome; reach forfield.custom(...).render(...)only when the value model itself is no longer one of the built-in field types
| 1 | import { field, useFormBridge } from '@runilib/react-formbridge' |
| 2 | import styles from './Checkout.module.css' |
| 3 | |
| 4 | const schema = { |
| 5 | projectName: field |
| 6 | .text('Project name') |
| 7 | .required() |
| 8 | .placeholder('Billing redesign'), |
| 9 | ownerEmail: field |
| 10 | .email('Owner email') |
| 11 | .required() |
| 12 | launchNotes: field |
| 13 | .textarea('Launch notes') |
| 14 | .hint('Textarea inherits the same shared theme.'), |
| 15 | } |
| 16 | |
| 17 | export function CheckoutForm() { |
| 18 | const form = useFormBridge(schema, { |
| 19 | validateOn: 'onTouched', |
| 20 | globalDefaults: () => ({ |
| 21 | form: { className: styles.form }, |
| 22 | submit: { |
| 23 | className: styles.submit, |
| 24 | loadingText: 'Saving...', |
| 25 | }, |
| 26 | field: { |
| 27 | classNames: { |
| 28 | wrapper: styles.field, |
| 29 | label: styles.label, |
| 30 | textInput: styles.input, |
| 31 | textarea: styles.input, |
| 32 | error: styles.error, |
| 33 | hint: styles.hint, |
| 34 | }, |
| 35 | }, |
| 36 | }), |
| 37 | }) |
| 38 | |
| 39 | return ( |
| 40 | <form.Form onSubmit={saveCheckout}> |
| 41 | <form.fields.projectName /> |
| 42 | <form.fields.ownerEmail |
| 43 | styles={{ |
| 44 | textInput: { borderColor: '#38bdf8' }, |
| 45 | }} |
| 46 | /> |
| 47 | <form.fields.launchNotes /> |
| 48 | <form.Form.Submit>Save theme</form.Form.Submit> |
| 49 | </form.Form> |
| 50 | ) |
| 51 | } |
Choose the right layer
- Use
useFormBridge(schema, { globalDefaults })when a whole screen or product area needs the same theme. This is the default recommendation for CSS Modules, StyleSheet, utility-class maps, or design-system-wide field chrome. - Use local props on
<fields.name classNames={...} />when one field needs a special variant without mutating the shared schema. - Use
form.fieldController(name)when a built-in field needs a fully custom trigger, shell, or modal while keeping the same schema contract. Usefield.custom(defaultValue).render(...)only when the value model itself is custom.
The public type surface guards these layers too: a text field does not expose textarea-only or select-only props, and native fields do not expose web-only props such as className.
Merge order is predictable: builder behavior → globalDefaults → local field props → field controller or custom render layer.
globalDefaults surface
Complete globalDefaults surface:
| Method | Type | Description |
|---|---|---|
globalDefaults | (state) => FormBridgeUiOptions | Function invoked on each render - can react to submit/dirty/error state |
field? | { classNames?, styles?, hideLabel?, highlightOnError?, readOnly?, wrapperProps?, ... } | Shared defaults for all generated fields |
form? (web) | { className?, style?, props? } | Form wrapper overrides on web |
form? (native) | { style?, props? } | Form wrapper overrides on native |
submit? (web) | { className?, style?, loadingText?, props? } | Submit button overrides on web |
submit? (native) | { style?, containerStyle?, textStyle?, indicatorColor?, loadingText?, props?, contentProps? } | Submit button overrides on native |
Typing rules
| Field family | Override prop | Platform |
|---|---|---|
| Text-like fields | inputProps | Web + Native |
textarea fields | textareaProps | Web only |
select fields | selectProps | Web only |
| Web fields | classNames / styles slot maps | Web only |
| Native fields | className, textareaProps, selectProps | Not exposed |
Recipe: shared theme with CSS Modules or StyleSheet
This is the most common production setup.
- One
globalDefaultsobject themes the form wrapper, every generated field, and the submit button - The schema stays reusable across pages because the visual system lives at the screen level
- You still keep an escape hatch for one field with local prop overrides
| 1 | const form = useFormBridge(schema, { |
| 2 | globalDefaults: () => ({ |
| 3 | form: { className: styles.formShell }, |
| 4 | submit: { |
| 5 | className: styles.submitButton, |
| 6 | loadingText: 'Applying CSS Modules theme...', |
| 7 | }, |
| 8 | field: { |
| 9 | classNames: { |
| 10 | wrapper: styles.formField, |
| 11 | label: styles.formLabel, |
| 12 | textInput: styles.formInput, |
| 13 | textarea: styles.formInput, |
| 14 | select: styles.formInput, |
| 15 | hint: styles.helperText, |
| 16 | error: styles.errorBox, |
| 17 | }, }, |
| 18 | }), |
| 19 | }) |
| 20 | |
| 21 | <form.Form onSubmit={save}> |
| 22 | <form.fields.projectName /> |
| 23 | <form.fields.ownerEmail |
| 24 | styles={{ |
| 25 | textInput: { borderColor: '#38bdf8' }, |
| 26 | }} |
| 27 | /> |
| 28 | <form.fields.department /> |
| 29 | <form.Form.Submit>Save CSS recipe</form.Form.Submit> |
| 30 | </form.Form> |
Host helpers: FieldHost, SubmitHost, FormHost
react-formbridge now exports official host components for styling libraries that want a stable component reference.
FieldHostrenders any generated field component passed through itsfieldpropSubmitHostrendersForm.Submitthrough a stablesubmitpropFormHostrenders the generatedFormcomponent through a stableformprop
These helpers are especially useful with styled-components, styled-components/native, or any wrapper-based styling system that works better with stable host components than with runtime-generated field references.
| 1 | import { |
| 2 | FieldHost, |
| 3 | FormHost, |
| 4 | field, |
| 5 | SubmitHost, |
| 6 | useFormBridge, |
| 7 | } from '@runilib/react-formbridge' |
| 8 | import styled from 'styled-components' |
| 9 | |
| 10 | const StyledForm = styled(FormHost)`` |
| 11 | display: flex; |
| 12 | flex-direction: column; |
| 13 | gap: 16px; |
| 14 | `` |
| 15 | |
| 16 | const EmailField = styled(FieldHost).attrs({ |
| 17 | inputProps: { autoComplete: 'email', inputMode: 'email' }, |
| 18 | })`` |
| 19 | & input { |
| 20 | border-radius: 16px; |
| 21 | border: 1px solid rgba(56, 189, 248, 0.28); |
| 22 | background: rgba(15, 23, 42, 0.74); |
| 23 | color: #f8fafc; |
| 24 | } |
| 25 | `` |
| 26 | |
| 27 | const SubmitButton = styled(SubmitHost)`` |
| 28 | min-width: 196px; |
| 29 | border-radius: 16px; |
| 30 | background: linear-gradient(135deg, #38bdf8, #22c55e); |
| 31 | color: #04121c; |
| 32 | font-weight: 800; |
| 33 | `` |
| 34 | |
| 35 | const form = useFormBridge({ |
| 36 | contactEmail: field.email('Contact email').required(), |
| 37 | }) |
| 38 | |
| 39 | <StyledForm form={form.Form} onSubmit={save}> |
| 40 | <EmailField field={form.fields.contactEmail} /> |
| 41 | <SubmitButton submit={form.Form.Submit}>Save styled recipe</SubmitButton> |
| 42 | </StyledForm> |
Recipe: styled-components on web and native
Choose this pattern when the app already uses styled-components or styled-components/native.
- Use the exported host helpers instead of rebuilding your own wrappers
- Keep the styled shell stable and pass the generated field, submit button, or form through props
- On web, normal CSS selectors can target
label,input,textarea, and the other built-in elements - On native, use
.attrs({ styles: { ... } })to feed slot styles into the generated field
This stable-host pattern is the safest documented recipe because generated field components are runtime artifacts. It keeps styling ergonomic without forcing users to hand-build every field.
| 1 | import { |
| 2 | FieldHost, |
| 3 | FormHost, |
| 4 | field, |
| 5 | SubmitHost, |
| 6 | useFormBridge, |
| 7 | } from '@runilib/react-formbridge' |
| 8 | import styled from 'styled-components' |
| 9 | |
| 10 | const StudioForm = styled(FormHost)`` |
| 11 | display: flex; |
| 12 | flex-direction: column; |
| 13 | gap: 18px; |
| 14 | `` |
| 15 | |
| 16 | const EmailShell = styled(FieldHost).attrs({ |
| 17 | inputProps: { autoComplete: 'email', inputMode: 'email' }, |
| 18 | })`` |
| 19 | display: flex; |
| 20 | flex-direction: column; |
| 21 | gap: 8px; |
| 22 | |
| 23 | & label { color: #dbeafe; font-size: 12px; font-weight: 700; } |
| 24 | & input { |
| 25 | border-radius: 16px; |
| 26 | border: 1px solid rgba(56, 189, 248, 0.28); |
| 27 | background: rgba(15, 23, 42, 0.74); |
| 28 | color: #f8fafc; |
| 29 | padding: 14px 16px; |
| 30 | } |
| 31 | & span { color: #94a3b8; font-size: 12px; } |
| 32 | `` |
| 33 | |
| 34 | const SubmitShell = styled(SubmitHost)`` |
| 35 | min-width: 196px; |
| 36 | border: none; |
| 37 | border-radius: 16px; |
| 38 | background: linear-gradient(135deg, #38bdf8, #22c55e); |
| 39 | color: #04121c; |
| 40 | font-weight: 800; |
| 41 | `` |
| 42 | |
| 43 | const form = useFormBridge({ |
| 44 | studioName: field.text('Studio name').required(), |
| 45 | contactEmail: field.email('Contact email').required(), |
| 46 | }) |
| 47 | |
| 48 | <StudioForm form={form.Form} onSubmit={save}> |
| 49 | <EmailShell field={form.fields.contactEmail} hint="Used for invoices only" /> |
| 50 | <SubmitShell submit={form.Form.Submit}>Save styled recipe</SubmitShell> |
| 51 | </StudioForm> |
Recipe: utility classes on web
This recipe is a strong fit for Tailwind, UnoCSS, Windi, or any class-based utility stack.
- The global
classNamesmap gives most of the form its look - Local
classNamesandinputPropscover one-off field variations - The runtime stays the same because the generated field still owns value, validation, and events
| 1 | const form = useFormBridge(schema, { |
| 2 | globalDefaults: () => ({ |
| 3 | form: { className: 'space-y-4' }, |
| 4 | submit: { |
| 5 | className: |
| 6 | 'inline-flex min-h-12 items-center justify-center rounded-2xl bg-cyan-400 px-5 font-semibold text-slate-950', |
| 7 | }, |
| 8 | field: { |
| 9 | classNames: { |
| 10 | wrapper: 'space-y-2', |
| 11 | label: |
| 12 | 'text-xs font-semibold uppercase tracking-[0.14em] text-slate-200', |
| 13 | textInput: |
| 14 | 'w-full rounded-2xl border border-slate-700 bg-slate-950/70 px-4 py-3 text-slate-50 outline-none', |
| 15 | textarea: |
| 16 | 'min-h-28 w-full rounded-2xl border border-slate-700 bg-slate-950/70 px-4 py-3 text-slate-50 outline-none', |
| 17 | select: |
| 18 | 'w-full rounded-2xl border border-slate-700 bg-slate-950/70 px-4 py-3 text-slate-50 outline-none', |
| 19 | hint: 'text-xs text-slate-400', |
| 20 | error: 'text-sm text-rose-300', |
| 21 | }, |
| 22 | }, |
| 23 | }), |
| 24 | }) |
| 25 | |
| 26 | <form.Form onSubmit={save}> |
| 27 | <form.fields.ownerEmail |
| 28 | classNames={{ |
| 29 | textInput: 'border-cyan-400 focus:ring-2 focus:ring-cyan-400/30', |
| 30 | }} |
| 31 | inputProps={{ |
| 32 | autoComplete: 'email', |
| 33 | inputMode: 'email', |
| 34 | }} |
| 35 | /> |
| 36 | <form.fields.launchNotes /> |
| 37 | <form.Form.Submit>Save utility recipe</form.Form.Submit> |
| 38 | </form.Form> |
Recipe: local slot overrides with no extra styling library
Use this when you want to prove the styling API quickly, or when one form needs a polished custom look without introducing a new styling dependency.
stylestargets the built-in slots directly on both web and nativerenderHint,renderError, andrenderRequiredMarkcover the cases where plain styles are not enough- This is also a good recipe for incrementally migrating an existing screen to react-formbridge
| 1 | const form = useFormBridge(schema, { |
| 2 | validateOn: 'onTouched', |
| 3 | globalDefaults: () => ({ |
| 4 | submit: { |
| 5 | loadingText: 'Saving inline theme...', |
| 6 | style: { |
| 7 | minWidth: 196, |
| 8 | borderRadius: 16, |
| 9 | background: 'rgba(245, 158, 11, 0.14)', |
| 10 | color: '#fde68a', |
| 11 | }, |
| 12 | }, |
| 13 | field: { |
| 14 | styles: { |
| 15 | wrapper: { marginBottom: 0, gap: 8 }, |
| 16 | label: { |
| 17 | color: '#f8fafc', |
| 18 | fontSize: 12, |
| 19 | fontWeight: 700, |
| 20 | letterSpacing: '0.05em', |
| 21 | textTransform: 'uppercase', |
| 22 | }, |
| 23 | textInput: { |
| 24 | background: 'rgba(15, 23, 42, 0.5)', |
| 25 | border: '1px solid rgba(251, 191, 36, 0.18)', |
| 26 | borderRadius: 16, |
| 27 | color: '#f8fafc', |
| 28 | padding: '14px 16px', |
| 29 | }, |
| 30 | textarea: { |
| 31 | minHeight: 108, |
| 32 | background: 'rgba(15, 23, 42, 0.5)', |
| 33 | border: '1px solid rgba(251, 191, 36, 0.18)', |
| 34 | borderRadius: 16, |
| 35 | color: '#f8fafc', |
| 36 | padding: '14px 16px', |
| 37 | }, |
| 38 | hint: { color: '#fde68a' }, |
| 39 | error: { color: '#fca5a5' }, |
| 40 | }, |
| 41 | renderRequiredMark: () => <span style={{ color: '#f59e0b' }}>•</span>, |
| 42 | }, |
| 43 | }), |
| 44 | }) |
| 45 | |
| 46 | <form.fields.receiptEmail |
| 47 | renderHint={(props) => ( |
| 48 | <span style={{ color: '#cbd5e1', fontSize: 12 }}> |
| 49 | We only use it for invoices and receipts. |
| 50 | </span> |
| 51 | )} |
| 52 | highlightOnError={false} |
| 53 | /> |
| 54 | |
| 55 | <form.fields.postalCode |
| 56 | styles={{ |
| 57 | textInput: { textAlign: 'center', letterSpacing: '0.14em' }, |
| 58 | }} |
| 59 | /> |
Web styling surface
On web, the API is broad enough to work with CSS Modules, utility classes, styled-components, Emotion, or plain objects.
classNames.wrapperandstyles.wrappertheme the field container directlyglobalDefaults.formandglobalDefaults.submitstyle the generated form wrapper and submit buttonhighlightOnErrorlets you opt out of the built-in red field chrome while keeping the error messagewrapperProps,labelProps,hintProps, anderrorPropslet you push DOM attributes without losing the generated rendererinputPropsis available on text-like web fields,textareaPropson textarea fields, andselectPropson select fieldsrenderLabel,renderHint,renderError, andrenderRequiredMarkcover the cases where styling alone is not enough
Web field slot names currently exposed through classNames / styles.
Slot names are prefixed with the field type so it is clear where each override will land.
| Field | Slot names |
|---|---|
| Shared (every field) | wrapper, label, hint, error, requiredMark |
| Text / email / number / tel / url / date | textInput |
| Textarea | textarea |
| Select | select, selectValue, selectArrow |
| Checkbox | checkboxRow, checkboxInput, checkboxLabel |
| Radio | radioGroup, radioOption, radioInput, radioLabel |
| Switch | switchRoot, switchButton, switchTrack, switchThumb, switchLabel |
| OTP | otpContainer, otpInput, otpSeparator |
| Password | passwordInput, passwordToggle, passwordStrengthRow, passwordStrengthBar, passwordStrengthMeta, passwordStrengthFill, passwordStrengthLabel, passwordStrengthEntropy, passwordRulesList, passwordRuleItem, passwordRuleBullet, passwordRuleText |
| Phone | phoneInput, phoneRow, phoneCountryButton, phoneCountryFlag, phoneCountryDivider, phoneChevron, phoneSearchInput, phoneSearchWrapper, phoneCountryList, phoneCountryScroll, phoneCountryItem, phoneSeparator, phoneCountryName, phoneCountryDial, phoneE164, phoneEmptyText |
| File | fileDropZone, fileDropZoneIcon, fileDropZoneText, fileDropZoneAccept, fileDropZoneMaxSize, fileBrowseButton, fileList, fileListItem, filePreviewImage, fileIcon, fileInfo, fileName, fileMeta, fileRemoveButton, fileAddMoreButton |
| Async autocomplete | autocompleteInput, autocompleteSelect, autocompleteSelectValue, autocompleteSelectArrow, autocompleteListbox, autocompleteOption, autocompleteOptionActive, autocompleteOptionSelected, autocompleteEmpty, autocompleteLoading |
Native styling surface
On React Native, the same layering applies, but the override points stay React Native-friendly instead of DOM-specific.
styles.wrapperthemes the field wrapper, whileglobalDefaults.formandglobalDefaults.submittheme the form container and submit buttonhighlightOnErrorlets you opt out of the built-in red field chrome while keeping the error message- Renderer-specific extra keys are also supported in
styles, which is especially useful for inputs such as checkboxes, async selectors, or modal option lists wrapperProps,labelProps,inputProps,hintProps, anderrorPropshelp with test IDs, accessibility, or integration with surrounding layout primitives- Native fields do not expose web-only props such as
className,textareaProps, orselectProps renderLabel,renderHint,renderError, andrenderRequiredMarkcover the cases where a simple style object is not enough
Native field slot names currently exposed through styles.
Slot names are prefixed with the field type so it is clear where each override will land.
| Field | Slot names |
|---|---|
| Shared (every field) | wrapper, label, error, hint, requiredMark |
| Text / email / number / tel / url / date | textInput |
| Checkbox | checkboxRow, checkboxBox, checkboxLabel |
| Switch | switchRow, switchLabel |
| Select / radio | selectTrigger, selectTriggerLabel, selectOptionRow, selectOptionLabel, selectModalBackdrop, selectModalCard |
| OTP | otpContainer, otpInput, otpSeparator |
| Password | passwordInput, passwordToggle, passwordToggleText, passwordStrengthRow, passwordStrengthBar, passwordStrengthMeta, passwordStrengthFill, passwordStrengthLabel, passwordStrengthEntropy, passwordRulesList, passwordRuleItem, passwordRuleBullet, passwordRuleText |
| Phone | phoneInput, phoneRow, phoneCountryButton, phoneCountryFlag, phoneCountryDial, phoneCountryDivider, phoneChevron, phoneE164, phoneModalBackdrop, phoneModalCard, phoneSearchInput, phoneSeparator, phoneCountryRow, phoneCountryName, phoneEmptyText |
| File | filePickButton, filePickButtonText, fileList, fileItem, fileIcon, fileIconText, fileName, fileMeta, fileRemoveButton, fileRemoveText |
| Async autocomplete | autocompleteTrigger, autocompleteTriggerValue, autocompleteTriggerPlaceholder, autocompleteModalBackdrop, autocompleteModalCard, autocompleteSearchInput, autocompleteLoadingRow, autocompleteLoadingText, autocompleteOptionRow, autocompleteOptionLabel, autocompleteEmptyText |
Use-case guide
- Use
globalDefaultswhen a whole route, modal, onboarding flow, or checkout screen should share one visual system - Keep platform-specific differences inside the
globalDefaultsobject at the screen level - Use local field props when one field needs a variant, a special helper text, or a different accent color on one screen
- Use the stable host recipe for
styled-componentsandstyled-components/native - Use
classNameonform/submitplusclassNamesslot maps on fields for Tailwind-style utility frameworks on web - Use
style,styles, wrappers, or the stable host recipe for React Native styling systems such as StyleSheet, NativeWind-friendly wrappers, or in-house component kits - Let the field type guide the override point:
inputPropsfor text-like fields,textareaPropsfor textareas,selectPropsfor selects - The API stays agnostic on purpose, so the same schema can power web and native without forcing the same styling stack on both platforms
Complete host helper exports:
Each host keeps the full props surface of the runtime component it wraps, including platform-native attributes.
| Host | Props |
|---|---|
FieldHost | generated field props + field |
SubmitHost | full submit props (including native button / pressable attrs) + submit |
FormHost | full form props (including native form / wrapper attrs) + form |