Skip to main content

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​

TextInput.tsx
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​

SelectInput.tsx
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​

Checkbox.tsx
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:

TextInput.tsx
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>
);
}
TextInput.module.css
.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:

form-theme.css
: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​