Custom UI Integration
Guide to integrate better-form with your UI library (shadcn/ui, Chakra, Mantine, etc.)
better-form is designed to work both out-of-the-box and with custom UI libraries. You can replace any component while keeping all validation and navigation logic.
Customization Levels
| Approach | Use Case | Complexity |
|---|---|---|
| Default Components | Use better-form out-of-the-box | Low |
| Custom Field Components | Replace only field inputs | Medium |
| Custom Structural Components | Replace navigation/step indicator | Medium |
| Full Headless | Complete control over all UI | High |
1. Custom Field Components (shadcn/ui)
The most common way to integrate your UI library is to replace field components.
"Full Field Component" Pattern
Each field component includes label, input, error, and helper text:
// components/fields/TextField.tsx
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { FieldComponentProps } from '@better_form/core';
export function TextField({
field,
value,
onChange,
error,
disabled
}: FieldComponentProps) {
return (
<div className="space-y-2">
{field.label && (
<Label htmlFor={field.id}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
)}
{field.description && (
<p className="text-sm text-muted-foreground">{field.description}</p>
)}
<Input
id={field.id}
type={field.inputType || 'text'}
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
className={error ? 'border-destructive' : ''}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{field.helperText && !error && (
<p className="text-sm text-muted-foreground">{field.helperText}</p>
)}
</div>
);
}Registering Field Components
import { WizardProvider } from '@better_form/core';
import { TextField } from '@/components/fields/TextField';
import { SelectField } from '@/components/fields/SelectField';
// ... other fields
const myFieldComponents = {
text: TextField,
email: TextField,
tel: TextField,
select: SelectField,
boolean: BooleanField,
// ... all types you use
};
function MyForm({ config }) {
return (
<WizardProvider config={config} fieldComponents={myFieldComponents}>
{/* Your custom container */}
</WizardProvider>
);
}SelectField Example with shadcn/ui
// components/fields/SelectField.tsx
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import type { FieldComponentProps } from '@better_form/core';
export function SelectField({
field,
value,
onChange,
error,
disabled
}: FieldComponentProps) {
return (
<div className="space-y-2">
{field.label && (
<Label>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
)}
<Select
value={value as string}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className={error ? 'border-destructive' : ''}>
<SelectValue placeholder={field.placeholder || 'Select...'} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem
key={String(option.value)}
value={String(option.value)}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}2. Full Headless Mode
For complete control, use only WizardProvider + useWizard and build your own UI.
Basic Structure
import { WizardProvider, useWizard } from '@better_form/core';
// Provider wrapper
function MyForm({ config, fieldComponents }) {
return (
<WizardProvider config={config} fieldComponents={fieldComponents}>
<MyWizardContainer />
</WizardProvider>
);
}
// Your custom container
function MyWizardContainer() {
const {
// State
currentStep,
visibleSteps,
visibleCurrentStepIndex,
formData,
errors,
isSubmitting,
canProceed,
// Navigation
nextStep,
previousStep,
goToStep,
submit,
// Field operations
setFieldValue,
getVisibleFields,
// Components
fieldComponents,
} = useWizard();
const visibleFields = getVisibleFields();
const isFirstStep = visibleCurrentStepIndex === 0;
const isLastStep = visibleCurrentStepIndex === visibleSteps.length - 1;
return (
<div className="my-custom-wizard">
{/* Custom Step Indicator */}
<MyStepIndicator
steps={visibleSteps}
currentIndex={visibleCurrentStepIndex}
/>
{/* Step Header */}
<div className="step-header">
<h2>{currentStep?.title}</h2>
{currentStep?.description && <p>{currentStep.description}</p>}
</div>
{/* Fields */}
<div className="fields space-y-6">
{visibleFields.map((field) => {
const FieldComponent = fieldComponents[field.type];
if (!FieldComponent) return null;
return (
<FieldComponent
key={field.id}
field={field}
value={formData[field.id]}
onChange={(val) => setFieldValue(field.id, val)}
error={errors[field.id]}
disabled={field.disabled || isSubmitting}
formData={formData}
/>
);
})}
</div>
{/* Custom Navigation */}
<div className="navigation flex justify-between mt-8">
{!isFirstStep && (
<Button variant="outline" onClick={previousStep}>
Back
</Button>
)}
<div className="ml-auto">
{isLastStep ? (
<Button onClick={submit} disabled={!canProceed || isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
) : (
<Button onClick={nextStep} disabled={!canProceed}>
Next
</Button>
)}
</div>
</div>
</div>
);
}3. Complete Example: shadcn/ui Integration
File Structure
components/
├── wizard/
│ ├── WizardContainer.tsx # Main container
│ ├── WizardStep.tsx # Step renderer
│ ├── WizardNavigation.tsx # Navigation buttons
│ ├── WizardStepIndicator.tsx # Step indicator
│ └── fields/
│ ├── index.ts # Export all fields
│ ├── TextField.tsx
│ ├── SelectField.tsx
│ ├── BooleanField.tsx
│ ├── RadioField.tsx
│ └── ...fields/index.ts
// components/wizard/fields/index.ts
import { TextField } from './TextField';
import { SelectField } from './SelectField';
import { BooleanField } from './BooleanField';
import { RadioField } from './RadioField';
import { TextareaField } from './TextareaField';
import { FileUploadField } from './FileUploadField';
export const fieldComponents = {
text: TextField,
email: TextField,
tel: TextField,
password: TextField,
url: TextField,
number: TextField,
date: TextField,
select: SelectField,
multiselect: SelectField,
boolean: BooleanField,
checkbox: BooleanField,
'single-checkbox': BooleanField,
radio: RadioField,
textarea: TextareaField,
file: FileUploadField,
};Usage
import { WizardProvider } from '@better_form/core';
import { WizardContainer } from '@/components/wizard/WizardContainer';
import { fieldComponents } from '@/components/wizard/fields';
function MyPage() {
const handleSubmit = async (data) => {
console.log('Form submitted:', data);
// API call
};
return (
<WizardProvider
config={myConfig}
fieldComponents={fieldComponents}
onSubmit={handleSubmit}
>
<WizardContainer />
</WizardProvider>
);
}4. useWizard Hook Reference
The useWizard hook exposes all wizard state and actions:
const {
// === Configuration ===
config, // Original WizardConfig
theme, // Current theme
fieldComponents, // Field components registry
// === State ===
state, // Complete state (WizardState)
formData, // Form data (alias of state.formData)
errors, // Validation errors
touched, // Touched fields
isLoading, // Loading in progress
isSubmitting, // Submit in progress
isDirty, // Form has been modified
// === Step ===
currentStep, // Current step (WizardStep)
visibleSteps, // Array of visible steps (with originalIndex)
visibleCurrentStepIndex, // Current step index among visible steps
// === Navigation ===
nextStep, // () => Promise<boolean> - Go forward
previousStep, // () => void - Go back
goToStep, // (index: number) => void - Go to specific step
submit, // () => Promise<void> - Submit form
// === Validation ===
canProceed, // boolean - Can proceed to next step
validateField, // (fieldId: string) => boolean
validateStep, // (stepIndex?: number) => boolean
// === Field Operations ===
setFieldValue, // (field: string, value: unknown) => void
setMultipleValues, // (values: Record<string, unknown>) => void
getFieldValue, // (field: string) => unknown
getVisibleFields, // (stepIndex?: number) => WizardField[]
isFieldVisible, // (field: WizardField) => boolean
isFieldDisabled, // (field: WizardField) => boolean
// === Blocking Dialog ===
showBlockingDialog, // boolean
setShowBlockingDialog,
blockingReason, // string
} = useWizard();5. FieldComponentProps Reference
Each field component receives these props:
interface FieldComponentProps {
// Field configuration
field: WizardField;
// Value and handlers
value: unknown;
onChange: (value: unknown) => void;
onBlur?: () => void;
// State
error?: string;
disabled?: boolean;
required?: boolean;
// Context
formData: Record<string, unknown>;
}
// WizardField contains:
interface WizardField {
id: string;
name: string;
label?: string;
type: FieldType;
required?: boolean;
placeholder?: string;
description?: string;
helperText?: string;
tooltip?: string;
options?: SelectOption[];
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
defaultValue?: unknown;
width?: FieldWidth;
transform?: TextTransform;
suffix?: string;
prefix?: string;
// ... and more
}Tips
Skip Default Styles
If using 100% custom UI, don't import @better_form/core/styles:
// Not needed if using full custom
// import '@better_form/core/styles';
// Only what you need
import { WizardProvider, useWizard } from '@better_form/core';Handling Field Visibility
Hidden fields (via showIf/hideIf) are not included in getVisibleFields(). Validation skips them automatically.
// getVisibleFields() returns only visible fields
const visibleFields = getVisibleFields();
// To check a specific field
const isVisible = isFieldVisible(field);
const isDisabled = isFieldDisabled(field);onChange Callbacks
If your field has onChange logic in the config, better-form handles it automatically when you call setFieldValue.
// In config
{
name: 'country',
type: 'select',
onChange: async (value, formData) => {
// This is called automatically
const provinces = await fetchProvinces(value);
return { provinces: provinces };
}
}
// In your component - just call setFieldValue
<SelectField
onChange={(val) => setFieldValue('country', val)}
/>
// better-form automatically executes the config callbackConditional FieldGroup Rendering
You can also render fieldGroups manually:
function MyWizardStep() {
const { currentStep, formData, errors, setFieldValue, fieldComponents } = useWizard();
return (
<div>
{currentStep?.fieldGroups.map((group) => (
<div key={group.id} className="field-group">
{group.title && <h3>{group.title}</h3>}
<div className={group.layout === 'horizontal' ? 'flex gap-4' : 'space-y-4'}>
{group.fields.map((field) => {
const FieldComponent = fieldComponents[field.type];
if (!FieldComponent) return null;
return (
<FieldComponent
key={field.id}
field={field}
value={formData[field.id]}
onChange={(val) => setFieldValue(field.id, val)}
error={errors[field.id]}
formData={formData}
/>
);
})}
</div>
</div>
))}
</div>
);
}