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