---
title: Form
description: Bind schema fields, validation, submission, and reset through createForm.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# Form

> Bind schema fields, validation, submission, and reset through createForm.

Use `createForm` for schema validation, submission, and reset. It returns a reactive Formisch store
with bound `form.Form` and `form.Field` components. Use standalone [Field](https://moraine.subf.dev/components/field.md)
when you only need labels and messages.

## Basic usage

```tsx
import { createForm, Input, Button, Switch, InputGroup, Icon } from 'moraine'
import * as v from 'valibot'

const schema = v.object({
  email: v.pipe(v.string(), v.email('Enter a valid email address')),
  enabled: v.boolean(),
})

export function NotificationForm() {
  const form = createForm({
    schema,
    initialInput: { email: '', enabled: false },
  })

  return (
    <form.Form onSubmit={(output) => console.log(output)}>
      <form.Field name="email" label="Email Address">
        <InputGroup>
          <InputGroup.Leading>
            <Icon name="i-lucide:mail" />
          </InputGroup.Leading>
          <Input />
        </InputGroup>
      </form.Field>
      <form.Field name="enabled" label="Enable Notifications">
        <Switch />
      </form.Field>
      <Button type="submit">Save Changes</Button>
    </form.Form>
  )
}
```

## Anatomy

```text
form.Form [component; slot=root; <form>]
└── form.Field [part]
```

Each `form.Field` uses the Field layout; the bound `form.Form` owns only its native form root.

## Usage

### Basic form and submission

Define a schema and initial input, then render the bound components returned by `createForm`.
`form.Form` prevents native navigation, validates the schema, and calls `onSubmit` with the
validated output. Native `action` and `method` attributes do not enable a browser navigation submit.

The returned store also supports Formisch's public methods and helpers, including programmatic
field focus.

```tsx
import { Button, createForm, Input } from 'moraine'
import { createSignal, Show } from 'solid-js'
import * as v from 'valibot'

const schema = v.object({
  username: v.pipe(v.string(), v.nonEmpty('Username is required')),
})

export function BasicForm() {
  const [submitted, setSubmitted] = createSignal<string | null>(null)
  const form = createForm({ schema, initialInput: { username: '' } })

  return (
    <div class="max-w-md w-full space-y-4">
      <form.Form
        onSubmit={(values) => {
          setSubmitted(values.username)
        }}
        class="space-y-4"
      >
        <form.Field name="username" label="Username" required>
          <Input placeholder="Enter username" />
        </form.Field>
        <Button type="submit">Save Profile</Button>
      </form.Form>
      <Show when={submitted()}>
        <p class="text-xs text-muted-foreground">
          Profile saved for user: <span class="text-foreground font-medium">{submitted()}</span>
        </p>
      </Show>
    </div>
  )
}
```

### Root styling

`<form.Form>` owns only its native form root. Use direct `class` and `style` props for an individual form, or configure `form.base.root` in your Theme. Slot maps remain available on `form.Field`, but the bound Form component does not accept `classes` or `styles`.

### Bound field outside the form root

`form.Field` captures the Formisch store from `createForm()`, so it can be used without rendering `form.Form`. Use the standalone [`Field`](https://moraine.subf.dev/components/field.md) when schema binding is not needed.

```tsx
const form = createForm({ schema })

<form.Field name="email" label="Email">
  <Input />
</form.Field>
```

### Field labels and messages

Configure `label`, `hint`, `description`, `help`, and `required` indicators on `<form.Field>`. Supported Moraine controls automatically link with helper descriptions and error messages via deterministic ARIA attributes.

```tsx
import { createForm, Input } from 'moraine'
import * as v from 'valibot'

export function LabelsMessages() {
  const form = createForm({
    schema: v.object({
      repoName: v.string(),
      deploymentTarget: v.string(),
    }),
    initialInput: { repoName: '', deploymentTarget: 'prod-us-east-1' },
  })

  return (
    <div class="max-w-md w-full space-y-4">
      <form.Field
        name="repoName"
        label="Repository name"
        description="Must be URL-friendly and unique within your organization."
        required
      >
        <Input placeholder="moraine-app" />
      </form.Field>

      <form.Field
        name="deploymentTarget"
        label="Deployment target"
        error="Selected cluster is currently unreachable."
      >
        <Input />
      </form.Field>
    </div>
  )
}
```

### Horizontal layout

Set `orientation="horizontal"` to switch from the default stacked layout to a side-by-side grid arrangement, common in settings panels and administrative consoles.

```tsx
import { createForm, Input, Select } from 'moraine'
import * as v from 'valibot'

export function HorizontalLayout() {
  const form = createForm({
    schema: v.object({
      displayName: v.string(),
      role: v.string(),
    }),
    initialInput: { displayName: '', role: '' },
  })

  return (
    <div class="mx-auto max-w-2xl w-full space-y-4">
      <form.Field
        name="displayName"
        orientation="horizontal"
        label="Display Name"
        description="Public name shown in activity feeds."
      >
        <Input placeholder="Moraine Team" />
      </form.Field>

      <form.Field name="role" orientation="horizontal" label="Default Role" required>
        <Select
          items={[
            { label: 'Developer', value: 'developer' },
            { label: 'Designer', value: 'designer' },
            { label: 'Manager', value: 'manager' },
          ]}
          placeholder="Select role"
        />
      </form.Field>
    </div>
  )
}
```

### Nested and array paths

Target nested object and array fields by supplying path tuples (for example, `['profile', 'email']`) to the `name` prop. Path types are fully validated and autocompleted based on your schema.

```tsx
import { Button, createForm, Input } from 'moraine'
import * as v from 'valibot'

export function NestedPath() {
  const form = createForm({
    schema: v.object({
      profile: v.object({
        name: v.pipe(v.string(), v.nonEmpty('Name is required.')),
        email: v.pipe(v.string(), v.email('Valid email is required.')),
      }),
    }),
    initialInput: { profile: { name: '', email: '' } },
  })

  return (
    <form.Form class="mx-auto max-w-xl w-full space-y-4">
      <form.Field name={['profile', 'name']} label="Profile Name" required>
        <Input placeholder="Moraine Team" />
      </form.Field>

      <form.Field name={['profile', 'email']} label="Profile Email" required>
        <Input type="email" placeholder="team@acme.dev" />
      </form.Field>

      <Button type="submit">Save Profile</Button>
    </form.Form>
  )
}
```

### Submission and loading state

`form.Form` does not automatically disable child controls during submission. Read `form.isSubmitting` to derive button loading spinners or disabled states while async form submissions are in flight.

```tsx
import { Button, createForm, Input } from 'moraine'
import { Show, createSignal } from 'solid-js'
import * as v from 'valibot'

const schema = v.object({ title: v.pipe(v.string(), v.nonEmpty('A title is required.')) })

function saveDraft(): Promise<void> {
  return new Promise((resolve) => window.setTimeout(resolve, 2000))
}

export function AsyncSubmit() {
  const [saved, setSaved] = createSignal(false)
  const form = createForm({ schema, initialInput: { title: '' } })
  const handleSubmit = async () => {
    setSaved(false)
    await saveDraft()
    setSaved(true)
  }

  return (
    <form.Form onSubmit={handleSubmit} class="max-w-sm space-y-4">
      <form.Field name="title" label="Draft title" required>
        <Input placeholder="Quarterly update" />
      </form.Field>
      <Button
        type="submit"
        leading="i-lucide:save"
        loading={form.isSubmitting}
        disabled={form.isSubmitting}
      >
        Save draft
      </Button>
      <Show when={saved()}>
        <p class="text-success text-sm">Saved.</p>
      </Show>
    </form.Form>
  )
}
```

### Reset and state restoration

Coordinate native reset events with Formisch: clicking a `type="reset"` button restores the initial store input snapshot and clears validation metadata.

```tsx
import { Button, createForm, Input } from 'moraine'
import { createSignal } from 'solid-js'
import * as v from 'valibot'

const schema = v.object({ name: v.pipe(v.string(), v.nonEmpty('A name is required.')) })

export function Reset() {
  const [resetCount, setResetCount] = createSignal(0)
  const form = createForm({ schema, initialInput: { name: 'Draft document' } })

  return (
    <form.Form onReset={() => setResetCount((count) => count + 1)} class="max-w-sm space-y-4">
      <form.Field name="name" label="Name">
        <Input />
      </form.Field>
      <div class="flex gap-2">
        <Button type="submit">Submit</Button>
        <Button type="reset" variant="outline">
          Reset
        </Button>
      </div>
      <p class="text-sm text-muted-foreground">Reset events: {resetCount()}</p>
    </form.Form>
  )
}
```

## Examples

### Schema validation

Formisch validates fields according to schema rules and updates errors in real-time or upon submission, linking error messages directly to the associated controls.

```tsx
import { Button, createForm, Input } from 'moraine'
import { Show, createSignal } from 'solid-js'
import * as v from 'valibot'

const schema = v.object({
  email: v.pipe(v.string(), v.email('Enter a valid email address.')),
  name: v.pipe(v.string(), v.minLength(2, 'Enter at least two characters.')),
})

export function Validation() {
  const [submitted, setSubmitted] = createSignal(false)
  const form = createForm({ schema, initialInput: { email: '', name: '' } })

  return (
    <form.Form onSubmit={() => setSubmitted(true)} class="max-w-sm space-y-4">
      <form.Field name="name" label="Name" required>
        <Input placeholder="Ada Lovelace" />
      </form.Field>
      <form.Field name="email" label="Email" required>
        <Input type="email" placeholder="ada@example.com" />
      </form.Field>
      <Button type="submit">Submit</Button>
      <Show when={submitted()}>
        <p class="text-success text-sm">Form submitted.</p>
      </Show>
    </form.Form>
  )
}
```

### Manual and server errors

Pass application or server-returned error strings directly to `<form.Field error={...}>` to force an error state or display external validation messages.

```tsx
import { Button, createForm, Input } from 'moraine'
import { createSignal } from 'solid-js'
import * as v from 'valibot'

export function ManualError() {
  const [error, setError] = createSignal<string | undefined>('Personal access token has expired.')
  const form = createForm({
    schema: v.object({ token: v.string() }),
    initialInput: { token: 'ghp_9f823a10bc47e' },
  })

  return (
    <div class="max-w-md w-full space-y-4">
      <form.Field
        name="token"
        label="Personal Access Token"
        hint="Fine-grained permissions"
        description="Required to synchronize remote repositories."
        error={error()}
        required
      >
        <Input
          type="password"
          onInput={() => {
            if (error()) {
              setError(undefined)
            }
          }}
          placeholder="Enter new token..."
        />
      </form.Field>

      <div class="flex gap-2">
        <Button
          size="sm"
          variant="secondary"
          onClick={() => setError('Invalid API token checksum. Please regenerate in settings.')}
        >
          Simulate Server Error
        </Button>
      </div>
    </div>
  )
}
```

### Render context

Provide a render function as child to `<form.Field>` to access the current effective `{ error }` while the child control continues to inherit the bound field state.

```tsx
import { Button, createForm, Input } from 'moraine'
import * as v from 'valibot'

export function RenderContext() {
  const form = createForm({
    schema: v.object({
      releaseTitle: v.pipe(v.string(), v.nonEmpty('Release title is required.')),
    }),
    initialInput: { releaseTitle: '' },
  })

  return (
    <form.Form class="mx-auto max-w-xl w-full space-y-4">
      <form.Field name="releaseTitle" label="Release Title" required>
        {(props) => <Input placeholder={props.error ? 'Title is required' : 'v2.14.0'} />}
      </form.Field>

      <Button type="submit">Create Draft</Button>
    </form.Form>
  )
}
```

### Mixed form controls

Compose multiple Moraine controls—such as `Input`, `Textarea`, `Select`, and `Checkbox`—within a single schema-validated form.

```tsx
import { Button, Checkbox, createForm, Input, Select, Textarea } from 'moraine'
import { Show, createSignal } from 'solid-js'
import * as v from 'valibot'

const schema = v.object({
  name: v.pipe(v.string(), v.nonEmpty('A project name is required.')),
  description: v.pipe(v.string(), v.nonEmpty('A project description is required.')),
  visibility: v.pipe(v.string(), v.nonEmpty('Please select visibility.')),
  terms: v.pipe(v.boolean(), v.literal(true, 'You must accept the terms and conditions.')),
})

export function MixedFields() {
  const [submitted, setSubmitted] = createSignal(false)
  const form = createForm({
    schema,
    initialInput: { name: '', description: '', visibility: '', terms: false },
  })

  return (
    <form.Form onSubmit={() => setSubmitted(true)} class="max-w-sm space-y-4">
      <form.Field name="name" label="Project name" required>
        <Input placeholder="Documentation refresh" />
      </form.Field>
      <form.Field name="description" label="Description" required>
        <Textarea placeholder="Project description" />
      </form.Field>
      <form.Field name="visibility" label="Visibility" required>
        <Select
          placeholder="Select visibility"
          items={[
            { label: 'Private', value: 'private' },
            { label: 'Team', value: 'team' },
          ]}
        />
      </form.Field>
      <form.Field name="terms" required>
        <Checkbox label="I accept the terms and conditions" />
      </form.Field>
      <Button type="submit">Create project</Button>
      <Show when={submitted()}>
        <p class="text-success text-sm">Project created.</p>
      </Show>
    </form.Form>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-submitting` | `form` | Present while the form is being submitted. |

## Props

### form.Form

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| onSubmit | ((output: InferOutput<TSchema>, event: SubmitEvent) => unknown) \| undefined | — | Called with validated schema output and the native submit event. |
| children | JSX.Element \| undefined | — | — |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | Classes \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | Styles \| undefined | — | Family slot style defaults for this instance. |

### form.Field

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| description | JSX.Element \| undefined | — | Description text shown below the label. |
| disabled | boolean \| undefined | — | Whether controls inherit a disabled state. |
| error | JSX.Element \| undefined | — | Custom error message or force error state. |
| help | JSX.Element \| undefined | — | Help text shown below the control when no error is present. |
| hiddenLabel | string \| undefined | — | Visually hidden label used when no non-empty visible label is provided. |
| hint | JSX.Element \| undefined | — | Hint text shown near the label. |
| id | string \| undefined | — | Unique identifier for the field. |
| label | JSX.Element \| undefined | — | Label for the field. |
| name* | FieldName<TSchema> | — | — |
| orientation | 'vertical' \| 'horizontal' \| undefined | 'vertical' | Visual layout direction. |
| readOnly | boolean \| undefined | — | Whether controls inherit a read-only state. |
| required | boolean \| undefined | false | Whether the field is required. |
| size | 'sm' \| 'md' \| 'lg' \| undefined | 'md' | Visual size of the component. |
| as | T \| undefined | 'div' | The HTML element or component to render as. |
| children | JSX.Element \| ((props: RenderProps) => JSX.Element) \| undefined | — | Content or render function receiving the field state. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | Classes \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | Styles \| undefined | — | Family slot style defaults for this instance. |
