Skip to content
Form, Please

Styling

Form, Please separates layout, structural markup, and control markup. Choose the narrowest styling method that gives you the required result.

RequirementUse
Responsive columns and spacingImport form-please/layout.css.
Colors, borders, typography, and state stylesSelect the generated data-* attributes in your CSS.
A class for one field, section, or arraySet its className property.
Different structural markupReplace one or more slots.
Different control markupRegister a custom control.

The native preset uses native HTML controls and the default slots. It does not include a visual theme. The Material UI preset uses Material UI controls and slots. Your application still owns the Material UI theme.

Add the responsive layout

Import the layout CSS once in your application entry point or root layout:

import "form-please/layout.css"
import "./forms.css"

The import is optional. It adds only structural grid and spacing rules. It does not set colors, typography, borders, or control appearance.

The file uses the fp cascade layer and low-specificity :where() selectors. Unlayered application CSS overrides these rules. If your CSS uses layers, put your theme layer after fp.

Configure columns and spans

Set columns on a section. Set span on a field, nested section, or array:

{
	kind: "section",
	id: "contact",
	title: "Contact details",
	columns: 2,
	children: [
		{
			kind: "field",
			path: "email",
			control: "text",
			label: "Email",
		},
		{
			kind: "field",
			path: "notes",
			control: "textarea",
			label: "Notes",
			span: "full",
		},
	],
}

The default grid accepts 1, 2, 3, and 4. The layout responds to the width of the current section, not the viewport width.

The packaged CSS implements only these four column counts. If a custom kit uses other values, its slots or application CSS must implement that grid. The Material UI preset implements its 12-column grid with Material UI.

Section widthResult
Less than 40remOne column.
At least 40remUp to two columns.
At least 64remUp to the requested three or four columns.

A numeric span uses the available columns at each width. span: "full" always uses the full row. Nested sections respond to their own width.

Change the layout gaps

Set the layout variables on the form or on a structural node. Descendants inherit the values.

export function AccountForm() {
	const form = kit.useForm(accountDefinition, {
		defaultValues: {
			accountType: "personal",
			email: "",
		},
	})
 
	return (
		<kit.AutoForm
			className="account-form"
			form={form}
			style={{
				"--fp-array-item-gap": "1.5rem",
				"--fp-column-gap": "1.25rem",
				"--fp-row-gap": "1rem",
				"--fp-stack-gap": "1rem",
			}}
		>
			<kit.Submit>Save account</kit.Submit>
		</kit.AutoForm>
	)
}
VariableDefaultPurpose
--fp-stack-gap0.75remSpace between content in forms, fields, sections, and arrays.
--fp-array-item-gap1remSpace between content in one array item.
--fp-column-gap1remHorizontal space in a section grid.
--fp-row-gap1remVertical space in a section grid.

Add an application theme

Add a class to kit.Form or kit.AutoForm. Scope your selectors to that class so multiple form themes can exist on one page.

.account-form {
	--fp-stack-gap: 1rem;
	color: #172033;
	font: 1rem/1.5 system-ui, sans-serif;
}
 
.account-form [data-fp-node="section"] {
	border: 1px solid #d8deea;
	border-radius: 0.75rem;
	padding: 1.25rem;
}
 
.account-form [data-fp-node="field"] > label {
	font-weight: 650;
}
 
.account-form
	[data-fp-node="field"]
	> :is(input:not([type="checkbox"]), select, textarea) {
	box-sizing: border-box;
	width: 100%;
	border: 1px solid #9aa7bd;
	border-radius: 0.5rem;
	padding: 0.625rem 0.75rem;
}
 
.account-form [data-fp-node="error-message"] {
	color: #b42318;
	font-size: 0.875rem;
}

This example targets the markup from createDefaultSlots() and the native controls. A custom slot or control can use different elements.

Style one path

Use data-fp-path when the field path is the stable styling identifier:

.account-form [data-fp-node="field"][data-fp-path="email"] {
	grid-column: 1 / -1;
}
 
.account-form
	[data-fp-node="field"][data-fp-path^="contacts."][data-fp-path$=".email"] {
	background: #f7f9fc;
}

Array paths contain the current item index, such as contacts.0.email. The index changes when a user moves or removes an item.

Use structural attributes

Generated elements expose stable attributes. Custom slots preserve the contract only when they spread rootProps and layoutProps.

AttributeLocation and meaning
data-fp-nodeIdentifies form, field, section, array, array-item, or error-message.
data-fp-pathContains the current path on fields, arrays, array items, and path-specific errors.
data-fp-spanContains the resolved span on fields, sections, and arrays.
data-fp-layout="grid"Identifies the element that arranges section children.
data-fp-columnsContains the resolved column count on the section layout element.

The default and Material UI slots also add hooks for array controls:

.account-form [data-fp-array-action="add"] { /* Add button */ }
.account-form [data-fp-array-action="move-up"] { /* Move-up button */ }
.account-form [data-fp-array-action="move-down"] { /* Move-down button */ }
.account-form [data-fp-array-action="remove"] { /* Remove button */ }
.account-form [data-fp-array-item-actions] { /* Item action group */ }
.account-form [data-fp-array-item-position] { /* Visible item position */ }

Style state

Boolean state attributes have an empty value when the state is true. The attribute is absent when the state is false. Test for presence:

/* Correct */
.account-form [data-fp-node="field"][data-invalid] {
	color: #b42318;
}
 
/* This selector does not match. */
.account-form [data-invalid="true"] {
	color: #b42318;
}
AttributeGenerated state
data-invalidA field or array has validation issues that the form displays.
data-dirty, data-touchedA field or array has the matching interaction state.
data-validatingA field or array is validating.
data-disabled, data-readonlyThe form, field, section, array, or array item has the resolved restriction.
data-requiredA field has the resolved required state.

data-invalid does not appear for a hidden validation issue. It appears after the field is touched or after the first submit attempt.

Native controls also receive aria-invalid="true" when their displayed errors make them invalid:

.account-form :is(input, select, textarea)[aria-invalid="true"] {
	border-color: #b42318;
	outline-color: #b42318;
}

Add static or resolved classes

Field, section, and array definitions accept className. Use a string for a fixed class. Use a synchronous resolver when the class depends on form input.

const accountDefinition = kit.defineForm(accountSchema, {
	ui: [
		{
			kind: "section",
			id: "account",
			title: "Account",
			columns: 2,
			className: ({ accountType }) => {
				if (accountType === "company") return "company-account"
				return "personal-account"
			},
			children: [
				{
					kind: "field",
					path: "email",
					control: "text",
					label: "Email",
					className: "account-email",
					span: "full",
				},
			],
		},
	],
})

The resolver receives the complete form input as its first argument. It receives { context } as its second argument:

const compactDefinition = contextualKit.defineForm(accountSchema, {
	ui: [
		{
			kind: "field",
			path: "email",
			control: "text",
			label: "Email",
			className: (_values, { context }) => {
				if (context.density === "compact") return "field-compact"
				return "field-comfortable"
			},
		},
	],
})

The kit resolves the class again when form input changes. It puts the result in the node's rootProps. A custom slot must spread rootProps. If the slot adds a class, it must also preserve rootProps.className.

Resolve classes from form values

Change the account type to Company. The resolver changes the section classes and shows the company-name field.

This is the shared profile form from Get started. Select Company to see its resolved Tailwind classes change.

Profile

Edit a personal or company profile.

Choose a PNG file. The File stays in the React Hook Form input.

Contacts

Add or reorder contacts. React Hook Form updates the array by index.

Change the account type to restyle it.

Return complete class strings from each branch. Tailwind can then discover all utilities at build time.

			className: ({ accountType }) => {
				if (accountType === "company") {
					return "rounded-2xl border border-amber-300 bg-amber-50 p-5 shadow-sm transition-colors dark:border-amber-700 dark:bg-amber-950/30"
				}
 
				return "rounded-2xl border border-emerald-300 bg-emerald-50 p-5 shadow-sm transition-colors dark:border-emerald-700 dark:bg-emerald-950/30"
			},

Do not construct utility names from fragments such as bg-${color}-50.

Replace structural markup

Slots own the markup around controls. They do not own the control markup. Start with createDefaultSlots() and replace only the slot that needs different markup.

function AppFieldSlot({
	rootProps,
	label,
	labelProps,
	description,
	descriptionProps,
	control,
	errors,
}: FieldSlotProps) {
	const className = ["app-field", rootProps.className].filter(Boolean).join(" ")
	let renderedLabel: ReactNode
	if (label !== undefined) {
		renderedLabel = (
			<label {...labelProps} htmlFor={labelProps.htmlFor}>
				{label}
			</label>
		)
	}
	let renderedDescription: ReactNode
	if (description !== undefined) {
		renderedDescription = <p {...descriptionProps}>{description}</p>
	}
 
	return (
		<div {...rootProps} className={className}>
			{renderedLabel}
			{renderedDescription}
			{control}
			{errors}
		</div>
	)
}
 
const appSlots = {
	...defaultSlots,
	Field: AppFieldSlot,
} satisfies FormKitSlots
 
export const appKit = createFormKit({ controls, slots: appSlots })

Spread rootProps on the slot root. Spread labelProps, descriptionProps, and layoutProps on their matching elements. These props preserve generated IDs, accessibility relationships, state hooks, and layout hooks.

Read Form kits for all six slot contracts and complete array and submit examples.

Style Material UI forms

The Material UI preset uses its own responsive Grid. Use the application theme for shared styles. Use props.sx for a control and slotOptions for a field, section, or array.

{
	kind: "section",
	id: "proposal",
	title: "Conference proposal",
	columns: 12,
	slotOptions: {
		sx: { width: "100%" },
		layoutSx: { alignItems: "start" },
	},
	children: [
		{
			kind: "field",
			path: "title",
			control: "text",
			label: "Proposal title",
			span: 7,
			props: { sx: { bgcolor: "background.paper" } },
		},
	],
}

Read Material UI with Yup for a complete themed example.