shadcn/ui
Map shadcn/ui components to Formhaus field types through the components prop of FormRenderer.
Setup
The example is a Vite project with Tailwind CSS v4, created by the shadcn CLI with the Base UI primitives:
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/reactComponent map
Each key is a field type. Types without an entry fall back to the built-in HTML fields.
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.
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>
);
}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:
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>
);
}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>
);
}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
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>
);
}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
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:
@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.