Core Concepts
Custom UI Integration

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

ApproachUse CaseComplexity
Default ComponentsUse better-form out-of-the-boxLow
Custom Field ComponentsReplace only field inputsMedium
Custom Structural ComponentsReplace navigation/step indicatorMedium
Full HeadlessComplete control over all UIHigh

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 callback

Conditional 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>
  );
}