Styling Guide
forma-react is headless — it ships zero CSS. You own all styles, which means full control over how your forms look.
This guide shows practical patterns for styling form components.
Tailwind CSS
The most common approach. Here's a complete set of styled field components:
Text Input
import type { TextComponentProps } from "@fogpipe/forma-react";
function TextInput({ field }: TextComponentProps) {
return (
<div className="space-y-1.5">
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
{field.label}
{field.required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<input
id={field.name}
type={field.fieldType === "email" ? "email" : "text"}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
placeholder={field.placeholder}
disabled={!field.enabled}
readOnly={field.readonly}
aria-invalid={field.touched && field.errors.length > 0}
aria-describedby={field.description ? `${field.name}-desc` : undefined}
className="block w-full rounded-md border border-gray-300 px-3 py-2
text-sm shadow-sm placeholder:text-gray-400
focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500
aria-[invalid=true]:border-red-500 aria-[invalid=true]:focus:ring-red-500
disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500"
/>
{field.description && (
<p id={`${field.name}-desc`} className="text-xs text-gray-500">
{field.description}
</p>
)}
{field.touched &&
field.errors.map((err, i) => (
<p key={i} className="text-sm text-red-600" role="alert">
{err.message}
</p>
))}
</div>
);
}
Select
import type { SelectComponentProps } from "@fogpipe/forma-react";
function SelectInput({ field }: SelectComponentProps) {
return (
<div className="space-y-1.5">
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
{field.label}
{field.required && <span className="text-red-500 ml-0.5">*</span>}
</label>
<select
id={field.name}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? null : e.target.value)
}
onBlur={field.onBlur}
disabled={!field.enabled}
aria-invalid={field.touched && field.errors.length > 0}
className="block w-full rounded-md border border-gray-300 px-3 py-2
text-sm shadow-sm
focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500
aria-[invalid=true]:border-red-500
disabled:cursor-not-allowed disabled:bg-gray-50"
>
<option value="">{field.placeholder || "Select..."}</option>
{field.options.map((opt) => (
<option key={String(opt.value)} value={opt.value}>
{opt.label}
</option>
))}
</select>
{field.touched &&
field.errors.map((err, i) => (
<p key={i} className="text-sm text-red-600" role="alert">
{err.message}
</p>
))}
</div>
);
}
Checkbox
import type { BooleanComponentProps } from "@fogpipe/forma-react";
function Checkbox({ field }: BooleanComponentProps) {
return (
<div className="flex items-start gap-2">
<input
id={field.name}
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
onBlur={field.onBlur}
disabled={!field.enabled}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600
focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
disabled:cursor-not-allowed disabled:opacity-50"
/>
<div>
<label
htmlFor={field.name}
className="text-sm font-medium text-gray-700"
>
{field.label}
</label>
{field.description && (
<p className="text-xs text-gray-500">{field.description}</p>
)}
</div>
</div>
);
}
CSS Modules
If you don't use Tailwind, CSS Modules work the same way:
import type { TextComponentProps } from "@fogpipe/forma-react";
import styles from "./TextInput.module.css";
function TextInput({ field }: TextComponentProps) {
const hasError = field.touched && field.errors.length > 0;
return (
<div className={styles.field}>
<label htmlFor={field.name} className={styles.label}>
{field.label}
{field.required && <span className={styles.required}>*</span>}
</label>
<input
id={field.name}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
placeholder={field.placeholder}
disabled={!field.enabled}
className={`${styles.input} ${hasError ? styles.inputError : ""}`}
/>
{field.touched &&
field.errors.map((err, i) => (
<p key={i} className={styles.error}>
{err.message}
</p>
))}
</div>
);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.required {
color: #ef4444;
margin-left: 2px;
}
.input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
}
.input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.inputError {
border-color: #ef4444;
}
.error {
font-size: 0.875rem;
color: #dc2626;
}
Error Styling
The visibleErrors Pattern
Forma evaluates validation rules immediately, so field.errors is always populated — even before the user touches a field. Use field.visibleErrors which is pre-filtered by interaction state:
// Recommended: use visibleErrors (pre-filtered by touched/submitted state)
{
field.visibleErrors.map((err, i) => (
<p key={i} className="error">
{err.message}
</p>
));
}
// Also correct: manual gating with field.touched
{
field.touched &&
field.errors.map((err, i) => (
<p key={i} className="error">
{err.message}
</p>
));
}
// Wrong: shows errors on page load
{
field.errors.map((err, i) => (
<p key={i} className="error">
{err.message}
</p>
));
}
Using aria-invalid for CSS
Set aria-invalid based on touched + errors, then target it in CSS:
<input
aria-invalid={field.touched && field.errors.length > 0}
className="input"
/>
/* Plain CSS */
.input[aria-invalid="true"] {
border-color: #ef4444;
}
/* Tailwind */
.input {
@apply aria-[invalid=true]:border-red-500;
}
This keeps your validation styling accessible and centralized.
Form Layout Patterns
Use the layout prop on FormRenderer to control the overall form structure:
Vertical Stack (Default)
import type { LayoutProps } from "@fogpipe/forma-react";
function StackLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
return (
<form onSubmit={onSubmit} className="space-y-6">
{children}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white
hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
);
}
<FormRenderer spec={spec} components={components} layout={StackLayout} />;
Card Layout
function CardLayout({ children, onSubmit, isSubmitting }: LayoutProps) {
return (
<form onSubmit={onSubmit}>
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm space-y-6">
{children}
</div>
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</div>
</form>
);
}
FieldWrapper
Use the fieldWrapper prop to add consistent structure around every field — labels, descriptions, error messages — without repeating yourself in every component:
import type { FieldWrapperProps } from "@fogpipe/forma-react";
function FieldWrapper({
field,
children,
errors,
touched,
required,
visible,
}: FieldWrapperProps) {
if (!visible) return null;
return (
<div className="space-y-1.5">
<label
htmlFor={field.id}
className="block text-sm font-medium text-gray-700"
>
{field.label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
{children}
{field.description && (
<p className="text-xs text-gray-500">{field.description}</p>
)}
{touched &&
errors.map((err, i) => (
<p key={i} className="text-sm text-red-600" role="alert">
{err.message}
</p>
))}
</div>
);
}
<FormRenderer
spec={spec}
components={components}
fieldWrapper={FieldWrapper}
/>;
When using a fieldWrapper, your individual components can focus solely on the input element:
function TextInput({ field }: TextComponentProps) {
return (
<input
id={field.name}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
placeholder={field.placeholder}
disabled={!field.enabled}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
);
}
Dark Mode
Use CSS custom properties to support light and dark themes:
:root {
--form-bg: #ffffff;
--form-text: #111827;
--form-border: #d1d5db;
--form-focus: #3b82f6;
--form-error: #ef4444;
--form-muted: #6b7280;
}
@media (prefers-color-scheme: dark) {
:root {
--form-bg: #1f2937;
--form-text: #f9fafb;
--form-border: #4b5563;
--form-focus: #60a5fa;
--form-error: #f87171;
--form-muted: #9ca3af;
}
}
Then reference the variables in your components:
<input
className="bg-[var(--form-bg)] text-[var(--form-text)]
border-[var(--form-border)]
focus:border-[var(--form-focus)] focus:ring-[var(--form-focus)]"
/>
Next Steps
- Component Mapping — Full component type reference
- FormRenderer — Layout, FieldWrapper, and PageWrapper props
- Advanced Features — Presentation variants (slider, NPS, radio cards)