react-formbridge
Browse documentation
Stylingv1.0.2

Styling

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 for field.custom(...).render(...) only when the value model itself is no longer one of the built-in field types
SharedTheme.web.tsxtsx
1import { field, useFormBridge } from '@runilib/react-formbridge'
2import styles from './Checkout.module.css'
3
4const 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
17export 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

  1. 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.
  2. Use local props on <fields.name classNames={...} /> when one field needs a special variant without mutating the shared schema.
  3. Use form.fieldController(name) when a built-in field needs a fully custom trigger, shell, or modal while keeping the same schema contract. Use field.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 behaviorglobalDefaults → local field props → field controller or custom render layer.

globalDefaults surface

Complete globalDefaults surface:

MethodTypeDescription
globalDefaults(state) => FormBridgeUiOptionsFunction 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 familyOverride propPlatform
Text-like fieldsinputPropsWeb + Native
textarea fieldstextareaPropsWeb only
select fieldsselectPropsWeb only
Web fieldsclassNames / styles slot mapsWeb only
Native fieldsclassName, textareaProps, selectPropsNot exposed

Recipe: shared theme with CSS Modules or StyleSheet

This is the most common production setup.

  • One globalDefaults object 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
CssModulesTheme.web.tsxtsx
1const 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.

  • FieldHost renders any generated field component passed through its field prop
  • SubmitHost renders Form.Submit through a stable submit prop
  • FormHost renders the generated Form component through a stable form prop

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.

Hosts.web.tsxtsx
1import {
2 FieldHost,
3 FormHost,
4 field,
5 SubmitHost,
6 useFormBridge,
7} from '@runilib/react-formbridge'
8import styled from 'styled-components'
9
10const StyledForm = styled(FormHost)``
11 display: flex;
12 flex-direction: column;
13 gap: 16px;
14``
15
16const 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
27const 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
35const 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.

StyledComponents.web.tsxtsx
1import {
2 FieldHost,
3 FormHost,
4 field,
5 SubmitHost,
6 useFormBridge,
7} from '@runilib/react-formbridge'
8import styled from 'styled-components'
9
10const StudioForm = styled(FormHost)``
11 display: flex;
12 flex-direction: column;
13 gap: 18px;
14``
15
16const 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
34const 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
43const 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 classNames map gives most of the form its look
  • Local classNames and inputProps cover one-off field variations
  • The runtime stays the same because the generated field still owns value, validation, and events
UtilityClasses.web.tsxtsx
1const 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.

  • styles targets the built-in slots directly on both web and native
  • renderHint, renderError, and renderRequiredMark cover the cases where plain styles are not enough
  • This is also a good recipe for incrementally migrating an existing screen to react-formbridge
SlotOverrides.web.tsxtsx
1const 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.wrapper and styles.wrapper theme the field container directly
  • globalDefaults.form and globalDefaults.submit style the generated form wrapper and submit button
  • highlightOnError lets you opt out of the built-in red field chrome while keeping the error message
  • wrapperProps, labelProps, hintProps, and errorProps let you push DOM attributes without losing the generated renderer
  • inputProps is available on text-like web fields, textareaProps on textarea fields, and selectProps on select fields
  • renderLabel, renderHint, renderError, and renderRequiredMark cover 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.

FieldSlot names
Shared (every field)wrapper, label, hint, error, requiredMark
Text / email / number / tel / url / datetextInput
Textareatextarea
Selectselect, selectValue, selectArrow
CheckboxcheckboxRow, checkboxInput, checkboxLabel
RadioradioGroup, radioOption, radioInput, radioLabel
SwitchswitchRoot, switchButton, switchTrack, switchThumb, switchLabel
OTPotpContainer, otpInput, otpSeparator
PasswordpasswordInput, passwordToggle, passwordStrengthRow, passwordStrengthBar, passwordStrengthMeta, passwordStrengthFill, passwordStrengthLabel, passwordStrengthEntropy, passwordRulesList, passwordRuleItem, passwordRuleBullet, passwordRuleText
PhonephoneInput, phoneRow, phoneCountryButton, phoneCountryFlag, phoneCountryDivider, phoneChevron, phoneSearchInput, phoneSearchWrapper, phoneCountryList, phoneCountryScroll, phoneCountryItem, phoneSeparator, phoneCountryName, phoneCountryDial, phoneE164, phoneEmptyText
FilefileDropZone, fileDropZoneIcon, fileDropZoneText, fileDropZoneAccept, fileDropZoneMaxSize, fileBrowseButton, fileList, fileListItem, filePreviewImage, fileIcon, fileInfo, fileName, fileMeta, fileRemoveButton, fileAddMoreButton
Async autocompleteautocompleteInput, 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.wrapper themes the field wrapper, while globalDefaults.form and globalDefaults.submit theme the form container and submit button
  • highlightOnError lets 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, and errorProps help with test IDs, accessibility, or integration with surrounding layout primitives
  • Native fields do not expose web-only props such as className, textareaProps, or selectProps
  • renderLabel, renderHint, renderError, and renderRequiredMark cover 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.

FieldSlot names
Shared (every field)wrapper, label, error, hint, requiredMark
Text / email / number / tel / url / datetextInput
CheckboxcheckboxRow, checkboxBox, checkboxLabel
SwitchswitchRow, switchLabel
Select / radioselectTrigger, selectTriggerLabel, selectOptionRow, selectOptionLabel, selectModalBackdrop, selectModalCard
OTPotpContainer, otpInput, otpSeparator
PasswordpasswordInput, passwordToggle, passwordToggleText, passwordStrengthRow, passwordStrengthBar, passwordStrengthMeta, passwordStrengthFill, passwordStrengthLabel, passwordStrengthEntropy, passwordRulesList, passwordRuleItem, passwordRuleBullet, passwordRuleText
PhonephoneInput, phoneRow, phoneCountryButton, phoneCountryFlag, phoneCountryDial, phoneCountryDivider, phoneChevron, phoneE164, phoneModalBackdrop, phoneModalCard, phoneSearchInput, phoneSeparator, phoneCountryRow, phoneCountryName, phoneEmptyText
FilefilePickButton, filePickButtonText, fileList, fileItem, fileIcon, fileIconText, fileName, fileMeta, fileRemoveButton, fileRemoveText
Async autocompleteautocompleteTrigger, autocompleteTriggerValue, autocompleteTriggerPlaceholder, autocompleteModalBackdrop, autocompleteModalCard, autocompleteSearchInput, autocompleteLoadingRow, autocompleteLoadingText, autocompleteOptionRow, autocompleteOptionLabel, autocompleteEmptyText

Use-case guide

  • Use globalDefaults when a whole route, modal, onboarding flow, or checkout screen should share one visual system
  • Keep platform-specific differences inside the globalDefaults object 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-components and styled-components/native
  • Use className on form / submit plus classNames slot 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: inputProps for text-like fields, textareaProps for textareas, selectProps for 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.

HostProps
FieldHostgenerated field props + field
SubmitHostfull submit props (including native button / pressable attrs) + submit
FormHostfull form props (including native form / wrapper attrs) + form