Skip to main content

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 when you only need labels and messages.

Basic usage#

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>
  )
}

Playground#

The slot controls below apply only to the form.Form root. To explore label, description, help, and error slots, use the Field playground.

Required

Your unique public handle.

Team

Your primary function on the team.

0–10 years

Years of experience in your role.

Optional

Brief introduction for your profile.

Receive periodic digests and security updates
Props
Slots

Anatomy#

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.

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 when schema binding is not needed.

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.

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.

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.

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="[email protected]" />
      </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.

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.

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.

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="[email protected]" />
      </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.

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.

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.

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
data-submittingSlot: formDescription: Present while the form is being submitted.

Props#

Form#

Renders a <form> element by default.

Prop

Field#

Prop