Forms & validationExplore · Adapt · Compose

Schema-first forms with Formisch

Schema-first forms with Preact, Formisch, Valibot and Kamod UI. Keep schema rules, field state and visual feedback connected. Start with one complete form, then compare native inputs, composite controls and dynamic field arrays using the same validation model. Explore the live examples, inspect their source and choose the composition that fits your interface before connecting it to your application's data.

The examples use @formisch/preact + valibot in a Preact project. Follow the live result and its source together: compare supported options in the API reference, refine presentation with the component styles guide, and use shared theme tokens for consistent colors and surfaces. Preserve keyboard behavior and meaningful labels as you replace the sample content.

In this guide 15 sections
Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Installation

Add Formisch and Valibot to the docs app. Kamod UI components stay responsible for layout and interaction, while Formisch owns the schema-backed form state.

pnpm add @formisch/preact valibot

The @/components/kamod-ui/… imports in these examples refer to local source files. Configure that alias when copying source, or use the corresponding exports from @kamod-ch/ui when installing the package. Connect the global theme CSS and Tailwind source detection before your first render.

Run the command in your application package. It adds the form and schema libraries to an existing Preact/Kamod setup. In this repository, the equivalent workspace command is pnpm --filter @kamod-ch/ui-docs add @formisch/preact valibot. Keep your existing dependencies and connect the global stylesheet once.

Usage

Create a Valibot object schema, pass it to useForm, wrap controls in FormischForm, and use FormischField render props to connect Kamod UI controls.

Follow the complete bug-report example before extracting a field. Import Field as FormischField and Form as FormischForm from@formisch/preact; keep Kamod’s Field for the visible wrapper. These components have different jobs, even though their names overlap.

The previews keep submitted data in memory for inspection. They do not persist it or call a backend. Use their Code panels as focused integration examples; their visual wrappers can differ from the full demos, whose source is linked in the header.

Form examples & patterns

Explore Formisch in different contexts, from the starting pattern to the compositions below. Begin with Demo, then compare the examples that match your content, state and layout. These are working patterns to adapt, not a list of interchangeable variant values: check the API reference before using a prop or combining behaviors. Pay particular attention to validation feedback, field relationships and what happens after submission.

Read the result and its implementation together. Use each showcase’s Code view to inspect imports and composition, or its Prompt view to prepare a setup or adaptation brief. Try the narrow preview and local theme controls before connecting real data, and use Reset to return an example to its initial state. Keep layout changes in class utilities where appropriate; follow the component styles guide for hierarchy and the theming guide for shared surfaces and colors. The accessibility notes explain what to preserve when making those changes.

Demo

This bug-report form validates on submit and revalidates as you edit after the first submit.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Input

Native inputs can spread field.props and normalize undefined to an empty string for controlled rendering.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Textarea

Textareas use the same binding model as inputs and can display length counters next to validation feedback.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Select

Composite controls use their value callback. Read field.input, pass it to the control, and call field.onChange with the next value.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Checkbox

For checkbox groups, keep arrays immutable: add with a new array and remove with filter.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Radio Group

RadioGroup maps one selected string to a Valibot picklist.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Switch

Switch maps a controlled boolean to Formisch state.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Complex Forms

Larger forms compose the same primitives for plan, billing, add-ons, and email preferences.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Resetting the Form

Call reset(form) to restore initial inputs and clear validation state. Reset buttons are type=button so they do not submit.

reset(form) resets the form store. The demo also clears its separately stored result. In your app, decide whether reset should clear a request error or restore freshly loaded server values. The preview toolbar’s Reset remounts only that example and retains its selected container width.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Array Fields

FieldArray exposes stable item IDs. Use insert and remove to manage dynamic rows and let Valibot enforce min and max lengths.

Keep row identity separate from field position: key rows by the IDs from array.items.value, and bind fields with their current index. Check removal in the middle of the list, an empty new row, the five-row limit and keyboard focus after removal.

Interactive previewTry it before you copy it
Live Preact example Local preview theme · Responsive styles follow the preview width

Approach

The integration is headless: Formisch supplies typed input, errors, and methods; Kamod UI supplies accessible Field, Input, Select, Checkbox, RadioGroup, Switch, Button, and Card primitives.

  • Valibot: permitted values, validation messages and inferred types.
  • Formisch: field state, validation timing and validated submission.
  • Kamod UI: labels, layout, control behavior and theme-aware feedback.
  • Your application: authorization, server validation, requests and recovery.

Keep one source of field state. Mirroring every input in a separate useState can make reset and validation disagree.

Form Methods

Import only the methods you need. The methods API can inspect, validate, submit, reset, focus, and update deeply nested fields or field arrays.

The following lines are independent operations on an existing form, with schema and initialInput supplied by your component. Do not execute the whole list during render. Call mutations from the relevant event handler.

src/forms/form-methods.ts
import { Field as FormischField, FieldArray, Form as FormischForm, insert, remove, reset, useForm } from "@formisch/preact";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Checkbox, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet, Input, RadioGroup, RadioGroupItem, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Switch, Textarea } from "@kamod-ch/ui";
import * as v from "valibot";
import { focus, getErrors, getInput, move, replace, setErrors, setInput, submit, swap, validate } from "@formisch/preact";

const form = useForm({ schema, initialInput, validate: "submit", revalidate: "input" });
getInput(form, { path: ["email"] });
setInput(form, { path: ["email"], input: "hello@kamod.ch" });
getErrors(form, { path: ["email"] });
setErrors(form, { path: ["email"], errors: ["Use a work email."] });
await validate(form, { shouldFocus: true });
submit(form);
focus(form, { path: ["email"] });
insert(form, { path: ["emails"], at: 0, initialInput: "" });
remove(form, { path: ["emails"], at: 0 });
move(form, { path: ["emails"], from: 0, to: 1 });
swap(form, { path: ["emails"], at: 0, and: 1 });
replace(form, { path: ["emails"], at: 0, initialInput: "team@kamod.ch" });
reset(form);
API reference

Props and data

Configure Formisch using the contracts below. Read the documented options alongside the source definitions to understand which values your application supplies and which details the component owns. Start with a prop or helper, then follow its type link to inspect the declaration without leaving the page.

The most important integration APIs are useForm, Form, Field, FieldArray, reset, insert, remove, and the optional methods shown below.

Type cards are extracted from this checkout’s TypeScript source at build time. Props inherited through HTMLAttributes, Omit or variant helpers remain references in those declarations; their fields are not flattened here. Match the reference to your installed version, and check a package’s exports before writing an import type. A type exported by a source file is not necessarily re-exported by @kamod-ch/ui.TypeScript

Form props and contracts

Read each option with its owning component. The
marker identifies required fields verified in that declaration. A ? means a field can be omitted; it does not promise a fallback value. Existing documented defaults remain alongside the descriptions. For callbacks, check the argument and return types before connecting your own state or services.

Example

The local demonstration wrappers accept an ID prefix so labels and controls stay unique when several examples appear on one page. This is a demo contract, not a prop of FormischForm.

Example documented props
Prop / typeDescription
idPrefix
stringExampleProps

A unique prefix used to build the form and control IDs in each example. Supply a different prefix for every mounted instance to preserve label associations.

Documented defaultNo default; required

The following integration points come from @formisch/preact. They describe the roles used throughout the examples; follow the package reference for their complete generic signatures.

Formisch integration contracts
Prop / typeDescription
useForm
@formisch/preact
Create a form store from a Valibot schema and initialInput; choose validate and revalidate timing.
Form
@formisch/preact
Pass the store with of and handle validated output in onSubmit. The service request remains yours.
Field
@formisch/preact
Use a typed path. Read input.value and errors.value; preserve native field.props or wire a composite control's callback.
FieldArray
@formisch/preact
Read items.value and use each stable item ID as the row key; use the current index in the field path.
reset
@formisch/preact
Restore initial inputs and clear form validation state. Reset any separate request or result state yourself.
insert / remove
@formisch/preact
Change rows through the form store so array state and validation stay connected.

This is an integration map, not a replacement for the Formisch API documentation. Check the installed version before adopting optional methods.

Data type reference

Expand a card to inspect and copy the exact declaration. The code header identifies its repository file; follow imported or inherited types in the implementation source when you need the complete dependency chain. Required-field summaries cover fields declared directly in that type. Source-only helpers and inferred schema outputs describe the local implementation, not additional props you can pass to every component.

ExampleProps
Local source declaration from FormischExamples.tsx. Referenced types retain their original names; inspect that file for imports and supporting definitions.
Required fields
idPrefix
BugReportOutput
Exported source declaration from FormischExamples.tsx. Referenced types retain their original names; inspect that file for imports and supporting definitions.

Anatomy

A typical form has a Valibot schema, a useForm call, a FormischForm, one or more FormischField blocks, Kamod Field wrappers, visible errors, and action buttons.

  1. Define the schema and create the store inside the form component.
  2. Wrap fields in FormischForm and give every control a stable label association.
  3. Bind the field value, display its errors and distinguish submit actions from secondary buttons.
  4. Use validated output for your request; keep pending, failure and success feedback visible.

Schema and Form Setup

Valibot is the single source of truth. Input and output types are inferred from the schema, so submit handlers receive validated data.

src/forms/contact-schema.ts
import * as v from "valibot";

export const ContactSchema = v.object({
  email: v.pipe(v.string(), v.email("Enter a valid email address.")),
});

export type ContactOutput = v.InferOutput<typeof ContactSchema>;

Pass this schema to useForm with initialInput: { email: "" }. Keep the schema outside the component when its rules are static, and the form store inside the component that owns the interaction.

Validation

Use Valibot pipes for length, email, picklist, array, and boolean constraints. Formisch returns field-level error strings that map directly to FieldError.

Client validation helps people correct mistakes; it does not authorize a request. Validate submitted data again on the server. Keep network or permission failures distinct from field constraints, and preserve the entered values so the user can recover.

Validation Modes

Choose when the first validation happens with validate, and when later checks happen with revalidate.

The snippets below compare options for an existing schema. Start with validate: "submit" and revalidate: "input" when you want feedback after the first attempt, then prompt correction while editing. Choose blur or live validation deliberately; avoid displaying untouched-field errors before someone has a chance to answer.

src/forms/validation-modes.ts
const submitOnly = useForm({ schema, validate: "submit" });
const blurFirst = useForm({ schema, validate: "blur", revalidate: "input" });
const live = useForm({ schema, validate: "input" });
const validateImmediately = useForm({ schema, validate: "initial" });
const revalidateOnBlur = useForm({ schema, validate: "submit", revalidate: "blur" });
const revalidateOnSubmit = useForm({ schema, validate: "blur", revalidate: "submit" });

Displaying Errors

Set invalid state on Field, aria-invalid on the actual control, and render FieldError only when Formisch has messages.

In the Preact adapter, field.input and field.errors are signals. Read their .value when branching, mapping errors or passing a primitive into a controlled input.

src/forms/field-feedback.ts
// Inside a FormischField render callback:
const value = typeof field.input.value === "string" ? field.input.value : "";
const invalid = Boolean(field.errors.value?.length);
const errors = field.errors.value?.map((message) => ({ message }));

Keep FieldLabel, the input ID and any aria-describedby targets in sync. A red border alone is not an error message. Use unique IDs if the same form appears more than once.

Accessibility

Build the complete interaction, including the parts outside Formisch. The notes below distinguish the current implementation from the labels, content and behavior your application supplies. Start with the live preview, check the props and data reference, and test the finished composition with real content rather than assuming that an unchanged visual example covers every use case.

Built-in behavior and defaults

Formisch manages typed field state and validation through a store; Kamod provides the rendered controls and field layout. In the Preact adapter, field.input and field.errors are signals, so read their .value when choosing displayed values or messages. Neither a schema nor a red field wrapper automatically supplies an accessible name, an error-description relationship or an appropriate focus destination. The examples use explicit IDs and labels; review each control's rendered HTML when adapting them. Preserve field.props for native inputs, and use the demonstrated value/change binding for composite controls rather than assuming every component accepts the same event contract.

Labels and relationships

Give every input a visible FieldLabel whose htmlFor matches the real input or labelable trigger ID. Use useId or an instance-specific prefix so repeated forms do not compete for the same label. Add IDs to persistent help text and FieldError, then reference the applicable IDs with aria-describedby on the actual control. Keep instructions available before submission and make corrections specific: Enter a valid email address is more useful than Invalid input. For grouped choices, use FieldSet/FieldLegend and individual option labels; a group question is not a replacement for each option's name. Put required, disabled and aria-invalid on controls where appropriate, not only on the visual Field wrapper.

Connect the visible error to the real input. This is a fragment for an existing FormischField render callback, not a standalone form. Your store must include a string email field; define a unique id in the owning component. Preserve the native handlers while adding explicit label/error relationships.

import { Field, FieldLabel, FieldError, Input } from "@kamod-ch/ui";

// Inside your existing typed FormischField render callback:
// field is supplied by <FormischField of={form} path={["email"]}>.
const invalid = Boolean(field.errors.value?.length);
const helpId = `${id}-help`;
const errorId = `${id}-error`;

return (
  <Field invalid={invalid}>
    <FieldLabel htmlFor={id}>Email address</FieldLabel>
    <Input {...field.props} id={id} type="email" autoComplete="email"
      value={typeof field.input.value === "string" ? field.input.value : ""}
      aria-invalid={invalid || undefined}
      aria-describedby={[helpId, invalid ? errorId : undefined].filter(Boolean).join(" ")} />
    <p id={helpId}>Use an address where you can receive account updates.</p>
    {invalid && <FieldError id={errorId}
      errors={field.errors.value?.map((message) => ({ message }))} />}
  </Field>
);

Keyboard and focus

Choose validation timing deliberately. A submit-first flow lets someone complete a field before being shown an error; revalidation on input can then help them correct it. After an unsuccessful submission, verify where Formisch's focus behavior lands for native inputs and for custom triggers; do not add a competing focus effect blindly. For a long form, consider a focused error summary with links to affected controls when inline feedback alone leaves errors hard to find. Do not move focus after each keystroke. In FieldArray, key rows by the stable IDs in array.items.value while using the current index for the field path. After adding or removing a row, choose an appropriate surviving field or Add action, and ensure each removal button identifies its row.

States and integration details

Treat schema errors, server-rejected values and network failures as distinct outcomes. Preserve entered data after a rejected request and give a visible retry path. During submission, show meaningful pending text and prevent duplicate requests without stranding focus on a removed button. A disabled button alone does not explain progress. FieldError already has alert semantics; avoid wrapping every error in another assertive live region or repeatedly announcing the same message from both a summary and each field. Keep success visible near the form when the task stays on the page. When resetting, clear any application-owned request/result state deliberately as well as resetting the form store, and explain destructive resets when they would discard substantial work.

Verify the complete interaction

Check behavior as well as markup. Work through these scenarios in the application where the component will be used. Automated checks can help find structural problems; also review the reading experience with a screen reader, keyboard focus and the actual feedback from your application.

  1. Submit an untouched form, correct one error at a time, then submit valid data. Verify label, required state, help text and error text for both native fields and custom select/switch triggers with a screen reader.
  2. Add several array rows, remove the middle row and reorder if supported. Confirm stable input identity, unique IDs, correct remaining values and a sensible focus destination after the focused row disappears.
  3. Simulate slow submission, server validation failure, network rejection, retry and reset. Keep values recoverable, feedback understandable and all actions reachable at narrow widths, enlarged text and in both themes.

Recheck the result after changing theme tokens, translations or class overrides. Include text enlargement, narrow layouts and reduced-motion settings where animation is present. Inspect the component source when a behavior differs from your expectation; a role or state attribute does not implement keyboard interaction by itself.

For background, read the WAI-ARIA authoring guidance and the WAI guide to form feedback. These explain the patterns; verify browser and assistive-technology behavior for the implementation and audience you actually support.

Sources

Related references for the ideas and APIs used on this page.

The original shadcn/ui Formisch guide is a reference for the form patterns. Its examples use React; this page integrates @formisch/preact with Kamod’s Preact components. Consult Formisch for form APIs and Valibot for schema APIs, and check each project’s license when reusing source.

Build it into your interface

Use the Formisch examples as a starting point, then review the behavior in the context of your own screen. Layout, state and content should work together.

Connect the behavior

Define initial input and schema together. Formisch owns field state and validation; Kamod owns control presentation and interaction. Replace the demo's submitted-data panel with your service call, validate again on the server and keep entered values available when a request fails.

Copy the composition, then connect its intent. Replace sample records and callbacks with your own data flow. For options such as value, defaultValue, open or onChange, use only those documented in this component's API; different components expose different contracts.

Give each piece of state one owner. Keep a temporary selection inside the interface when nothing else needs it; lift shared values into a parent when other controls depend on them. Read the prop reference before combining controlled values with defaults. A default normally initializes a control; it is not a substitute for updating its current value.

Connect requests in an event handler or your application’s data layer, never as a side effect of rendering. Keep pending, completed and failed outcomes distinct, preserve useful input after an error, and offer a clear recovery action. If the integration subscribes to an external source, remove that subscription when its owner unmounts; cancel or ignore obsolete requests so an earlier response cannot replace newer results.

Keep the schema’s output type at the service boundary. The example below uses the ContactSchema from Schema and setup; supply your own service through onSubmit and return its promise so the form can track the submission. Display request errors near the form and reset only after a confirmed success or an explicit user action.

src/forms/contact-contract.ts
import type { InferOutput } from "valibot";
import { ContactSchema } from "./contact-schema";

type ContactOutput = InferOutput<typeof ContactSchema>;

// Pass a typed service into the form instead of embedding a demo endpoint.
type ContactFormProps = {
  onSubmit: (values: ContactOutput) => Promise<void>;
};

// Inside your form component, keep the submission promise connected:
// <FormischForm of={form} onSubmit={(values) => onSubmit(values)}>
//   ...fields and a submit button...
// </FormischForm>

Start with the live preview, then exercise the same interaction with your actual data. The type definitions describe accepted values; they do not implement persistence, navigation or server-side validation for you. Keep those responsibilities in the application that composes the UI.

Refine the presentation

Let a parent own the surrounding spacing. Keep related elements together with gap and use semantic surface/foreground pairs so the composition follows the active theme. The wrapper below changes the surrounding layout without assuming extra props on Formisch.

src/components/ExampleSurface.tsx
import type { ComponentChildren } from "preact";

export function ExampleSurface({ children }: { children: ComponentChildren }) {
  return (
    <div class="grid min-w-0 gap-4 rounded-lg border border-border bg-card p-4 text-card-foreground sm:p-6">
      {children}
    </div>
  );
}

Put the chosen example inside ExampleSurface, then refine supported variants and sizes using the API above. For application-wide changes, adjust theme tokens instead of repeating fixed colors. See Component styles for hierarchy, density and focus treatments.

Review the complete interaction

  • Submit empty and invalid values, correct them, then verify success and reset. Show request failures separately from client validation.
  • Add and remove array rows while preserving stable keys, explicit labels and useful focus. Enforce limits in both the schema and the interface.
  • Test keyboard operation, validation announcements and pending submissions in both schemes. A disabled submit action needs a visible explanation.

The preview's narrow control changes its container width. Also resize the browser when checking viewport-based Tailwind utilities such as sm: and md:, and test overlays in the full page where their portals render.

Compose a complete interface

Formisch is one part of the interaction. Choose companion components for the information or actions that still need a place on the screen. The references below are suggestions to compose deliberately, not extra dependencies you must add to every example.

Keep one source of truth for the operation and let each component communicate a different part of it. For example, waiting, no results and failure need different explanations. Avoid showing contradictory states together, and keep recovery actions close to the message that explains them.

Start with the smallest composition that explains the task. Browse the component library for individual controls or the block collections for complete layouts. Reuse shared theme tokens so these pieces feel consistent in light and dark mode.

Sources & design references

Use the Kamod examples for the working Preact composition, the package documentation for behavior, and the original guide for the form patterns behind it.

Follow a complete example from field to submission. The local source connects Field, validation feedback and Kamod controls. Compare its bindings with the props and contracts before replacing the demo’s submit handler with your own request.

Match APIs to the versions in your package.json and lockfile; the repository’s main branch may be newer. Retain the applicable license notices when reusing source.

Design reference & package guides

Each reference answers a different question. Formisch owns field state, Valibot owns schema validation, and Kamod supplies the interface. The shadcn/ui guide provides the reference patterns; it is separate from the Preact implementation linked above.

Formisch
Form state, field paths and submission behavior. Use the @formisch/preact adapter for these examples.
Valibot
Schema rules, validation messages and inferred types. Keep these rules close to your form’s data model.
shadcn/ui
The original Formisch guide referenced by this page. Compare its form patterns; its code uses React, while these examples use Preact and Kamod.

Bring it back to your app

Start with one documented form pattern, connect your data and request, then work through the accessibility guidance. Keep labels, errors and focus behavior intact while refining spacing with the styles guide. Put shared colors in your theme tokens so the form belongs naturally beside the rest of your interface.