Skip to content

shadcn/ui ​

Map shadcn/ui components to Formhaus field types through the components prop of FormRenderer.

Open in StackBlitz · Source

Setup ​

The example is a Vite project with Tailwind CSS v4, created by the shadcn CLI with the Base UI primitives:

bash
pnpm dlx shadcn@latest init --template vite --base base --preset nova
pnpm dlx shadcn@latest add input select checkbox radio-group button label card
pnpm add @formhaus/core @formhaus/react

Component map ​

Each key is a field type. Types without an entry fall back to the built-in HTML fields.

ts
import type { FieldComponentMap } from '@formhaus/react';
import { CheckboxField } from './fields/CheckboxField';
import { RadioField } from './fields/RadioField';
import { SelectField } from './fields/SelectField';
import { TextField } from './fields/TextField';

export const components: FieldComponentMap = {
  text: TextField,
  email: TextField,
  phone: TextField,
  number: TextField,
  password: TextField,
  date: TextField,
  datetime: TextField,
  select: SelectField,
  radio: RadioField,
  checkbox: CheckboxField,
};

Fields ​

Every field receives FieldComponentProps: the field definition, value, error, loading, disabled, onChange and onBlur.

tsx
import type { FormField } from '@formhaus/core';
import type { ReactNode } from 'react';
import { Label } from '@/components/ui/label';

interface FieldShellProps {
  field: FormField;
  error?: string;
  group?: boolean;
  children: ReactNode;
}

export function FieldShell({ field, error, group, children }: FieldShellProps) {
  return (
    <div className="grid gap-2">
      <Label id={`${field.key}-label`} htmlFor={group ? undefined : field.key}>
        {field.label}
      </Label>
      {children}
      {error ? (
        <p className="text-sm text-destructive">{error}</p>
      ) : field.helperText ? (
        <p className="text-sm text-muted-foreground">{field.helperText}</p>
      ) : null}
    </div>
  );
}
tsx
import type { FieldComponentProps } from '@formhaus/react';
import { Input } from '@/components/ui/input';
import { FieldShell } from './FieldShell';

const inputTypes: Record<string, string> = {
  email: 'email',
  password: 'password',
  number: 'number',
  phone: 'tel',
  date: 'date',
  datetime: 'datetime-local',
};

export function TextField({ field, value, error, loading, disabled, onChange, onBlur }: FieldComponentProps) {
  return (
    <FieldShell field={field} error={error}>
      <Input
        id={field.key}
        type={inputTypes[field.type] ?? 'text'}
        value={value != null ? String(value) : ''}
        placeholder={field.placeholder}
        aria-invalid={!!error}
        disabled={disabled || loading}
        onChange={(event) => onChange(field.type === 'number' && event.target.value !== '' ? Number(event.target.value) : event.target.value)}
        onBlur={onBlur}
      />
    </FieldShell>
  );
}

The Base UI Select takes the options as items, so SelectValue shows the option label instead of the stored value:

tsx
import type { FieldComponentProps } from '@formhaus/react';
import { useState } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { FieldShell } from './FieldShell';

export function SelectField({ field, value, error, loading, disabled, onChange, onBlur }: FieldComponentProps) {
  const options = field.options ?? [];
  const [open, setOpen] = useState(false);

  return (
    <FieldShell field={field} error={error}>
      <Select
        items={options}
        value={typeof value === 'string' && value !== '' ? value : null}
        disabled={disabled || loading}
        open={open}
        onOpenChange={(next) => {
          setOpen(next);
          if (!next) onBlur();
        }}
        onValueChange={(next) => onChange(next ?? '')}
      >
        <SelectTrigger
          id={field.key}
          className="w-full"
          aria-invalid={!!error}
          onBlur={() => {
            if (!open) onBlur();
          }}
        >
          <SelectValue placeholder={field.placeholder ?? 'Select...'} />
        </SelectTrigger>
        <SelectContent>
          {options.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </FieldShell>
  );
}
tsx
import type { FieldComponentProps } from '@formhaus/react';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { FieldShell } from './FieldShell';

export function RadioField({ field, value, error, loading, disabled, onChange, onBlur }: FieldComponentProps) {
  return (
    <FieldShell field={field} error={error} group>
      <RadioGroup
        id={field.key}
        aria-labelledby={`${field.key}-label`}
        value={typeof value === 'string' ? value : null}
        disabled={disabled || loading}
        aria-invalid={!!error}
        onValueChange={(next) => onChange(next)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget)) onBlur();
        }}
      >
        {(field.options ?? []).map((option) => (
          <Label key={option.value} className="font-normal">
            <RadioGroupItem value={option.value} />
            {option.label}
          </Label>
        ))}
      </RadioGroup>
    </FieldShell>
  );
}
tsx
import type { FieldComponentProps } from '@formhaus/react';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';

export function CheckboxField({ field, value, error, loading, disabled, onChange, onBlur }: FieldComponentProps) {
  return (
    <div className="grid gap-2">
      <Label className="font-normal">
        <Checkbox
          id={field.key}
          checked={value === true}
          disabled={disabled || loading}
          aria-invalid={!!error}
          onCheckedChange={(checked) => onChange(checked)}
          onBlur={onBlur}
        />
        {field.label}
      </Label>
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  );
}

Actions and progress ​

tsx
import type { FormActionsProps } from '@formhaus/react';
import { Button } from '@/components/ui/button';

export function FormActions({
  primaryLabel,
  showPrimary = true,
  showBack,
  backLabel,
  loading,
  onPrimary,
  onPrev,
}: FormActionsProps) {
  return (
    <div className="flex justify-between gap-2 pt-2">
      {showBack ? (
        <Button type="button" variant="outline" disabled={loading} onClick={onPrev}>
          {backLabel}
        </Button>
      ) : (
        <span />
      )}
      {showPrimary && (
        <Button type="button" disabled={loading} onClick={onPrimary}>
          {primaryLabel}
        </Button>
      )}
    </div>
  );
}
tsx
import type { FormStepProgressProps } from '@formhaus/react';

export function StepProgress({ current, total, stepTitle }: FormStepProgressProps) {
  return (
    <div className="grid gap-1 pb-2">
      <p className="text-sm text-muted-foreground">
        Step {current} of {total}
      </p>
      {stepTitle && <h2 className="text-lg font-medium">{stepTitle}</h2>}
    </div>
  );
}

App ​

tsx
import type { FormDefinition } from '@formhaus/core';
import { FormRenderer } from '@formhaus/react';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { components } from './component-map';
import json from './definition.json';
import { FormActions } from './FormActions';
import { StepProgress } from './StepProgress';

const definition = json as FormDefinition;

export function App() {
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);

  return (
    <main className="mx-auto max-w-md px-4 py-8">
      <Card>
        <CardHeader>
          <CardTitle>{definition.title}</CardTitle>
        </CardHeader>
        <CardContent>
          {submitted ? (
            <pre className="text-sm">{JSON.stringify(submitted, null, 2)}</pre>
          ) : (
            <FormRenderer
              definition={definition}
              components={components}
              ActionsComponent={FormActions}
              ProgressComponent={StepProgress}
              onSubmit={setSubmitted}
            />
          )}
        </CardContent>
      </Card>
    </main>
  );
}

FormRenderer wraps fields in .fh-form and .fh-form__fields. Without @formhaus/core/style.css, add the spacing in your stylesheet:

css
@layer components {
  .fh-form,
  .fh-form__fields {
    @apply grid gap-5;
  }
}

Server re-validation ​

The example validates in the browser only. To check the same rules on the server, build a FormEngine from the same definition and call validate(), then pass the returned errors to the errors prop. See Next.js for a complete route handler.