# Form

<FrameworkCodeSwitcher>
  <div slot="astro">
```astro
---
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
---

<div class="w-full max-w-md" data-profile-form>
  <Form>
    <FormErrorSummary aria-label="Profile errors">
      <p class="font-medium">Review the highlighted fields.</p>
    </FormErrorSummary>
    <FieldGroup>
      <Field name="email">
        <FieldLabel>Email</FieldLabel>
        <FieldControl type="email" required placeholder="you@example.com" />
        <FieldError match="valueMissing">Enter your email address.</FieldError>
        <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
      </Field>
      <Button type="submit">Save profile</Button>
    </FieldGroup>
    <output class="text-muted-foreground mt-3 hidden text-sm" aria-live="polite"></output>
  </Form>
</div>

<script>
  const form = document.querySelector<HTMLFormElement>("[data-profile-form] form");
  const output = form?.querySelector<HTMLOutputElement>("output");

  if (form) {
    form.addEventListener("submit", (event) => {
      event.preventDefault();
      const email = String(new FormData(form).get("email") ?? "");
      if (output) {
        output.value = `Submitted email: ${email}`;
        output.classList.remove("hidden");
        output.classList.add("block");
      }
    });
  }
</script>
```
  </div>
  <div slot="react">
```tsx
import { useState } from "react";
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";

export function Example() {
  const [submittedEmail, setSubmittedEmail] = useState<string | null>(null);

  return (
    <div className="w-full max-w-md">
      <Form
        onSubmit={(event) => {
          event.preventDefault();
          const data = new FormData(event.currentTarget);
          setSubmittedEmail(String(data.get("email") ?? ""));
        }}
      >
        <FormErrorSummary aria-label="Profile errors">
          <p className="font-medium">Review the highlighted fields.</p>
        </FormErrorSummary>
        <FieldGroup>
          <Field name="email">
            <FieldLabel>Email</FieldLabel>
            <FieldControl
              type="email"
              required
              placeholder="you@example.com"
            />
            <FieldError match="valueMissing">Enter your email address.</FieldError>
            <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
          </Field>
          <Button type="submit">Save profile</Button>
        </FieldGroup>
        {submittedEmail !== null && (
          <output className="text-muted-foreground mt-3 block text-sm" aria-live="polite">
            Submitted email: {submittedEmail}
          </output>
        )}
      </Form>
    </div>
  );
}
```
  </div>
  <div slot="vue">
```vue
<script setup lang="ts">
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
import { ref } from "vue";

const result = ref("");
function submit(event: Event) {
  const form = event.target as HTMLFormElement;
  result.value = `Submitted email: ${String(new FormData(form).get("email") ?? "")}`;
}
</script>

<template>
  <div class="w-full max-w-md" data-profile-form>
    <Form @submit.prevent="submit">
      <FormErrorSummary aria-label="Profile errors">
        <p class="font-medium">Review the highlighted fields.</p>
      </FormErrorSummary>
      <FieldGroup>
        <Field name="email">
          <FieldLabel>Email</FieldLabel>
          <FieldControl type="email" required placeholder="you@example.com" />
          <FieldError match="valueMissing">Enter your email address.</FieldError>
          <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
        </Field>
        <Button type="submit">Save profile</Button>
      </FieldGroup>
      <output v-if="result" class="text-muted-foreground mt-3 block text-sm" aria-live="polite">{{
        result
      }}</output>
    </Form>
  </div>
</template>
```
  </div>
</FrameworkCodeSwitcher>

Form remains a native HTML form. The Runtime adds validation timing, field coordination, and an
accessible error summary without requiring React or a form-state library.

## Installation

```bash
npx starwind@latest add form --framework astro
```
</DocsTabsContent>
```bash
npx starwind@latest add form --framework react
```
</DocsTabsContent>
```bash
npx starwind@latest add form --framework vue
```
</DocsTabsContent>
</DocsTabs>

## Usage

The browser still owns submission and `FormData`. Use ordinary `action`, `method`, `submit`, and
`reset` behavior; Runtime controls serialize through their installed hidden inputs.

> **Tip:** Most forms do not need to set a validation policy. Starwind waits until submit to validate and
reveal errors, then validates accepted corrections on change after the first submission attempt.

## Understand validation

Form separates two questions: when a Field is validated, and when an existing error is revealed.
Validation itself has a before-submit phase and an after-submit phase.

<ol class="not-content my-6 grid gap-3 sm:grid-cols-2">
<li class="border-border bg-card rounded-lg border p-4">
<p class="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">1. Interact</p>
<p class="font-medium">A value changes, focus leaves a Field, the Form submits, or code requests validation.</p>
</li>
<li class="border-border bg-card rounded-lg border p-4">
<p class="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">2. Validate</p>
<p class="font-medium">The current before- or after-submit timing decides whether validators run.</p>
</li>
<li class="border-border bg-card rounded-lg border p-4">
<p class="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">3. Reveal</p>
<p class="font-medium">Error visibility independently decides whether existing errors may render.</p>
</li>
<li class="border-border bg-card rounded-lg border p-4">
<p class="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">4. Continue</p>
<p class="font-medium">Valid submission proceeds; invalid submission focuses the first invalid Field.</p>
</li>
</ol>

### Validation policy

| Setting | What it controls | Default |
| --- | --- | --- |
| `validationTiming` | Interaction validation before the first submission attempt | `submit` |
| `revalidationTiming` | The interaction timing that replaces `validationTiming` after a submission attempt | `change` |
| `errorVisibility` | Which cause makes existing errors eligible to render | `submit` |

| Value | Meaning |
| --- | --- |
| `change` | Every accepted value revision from a native or Runtime control, including typing, selecting, checking, and sliding |
| `blur` | Focus leaves the entire Field, rather than moving between descendants of a composite Field |
| `submit` | A whole-Form submission attempt |
| `manual` | Imperative `validate()`; no automatic interaction matches it |

### Recommended policies

| Goal | Before submit | After submit | Reveal errors |
| --- | --- | --- | --- |
| Calm by default, responsive during correction | `submit` | `change` | `submit` |
| Live validation | `change` | `change` | `change` |
| Submit-only feedback | `submit` | `submit` | `submit` |
| Imperative wizard or step validation | `manual` | `manual` | `manual` |

<details class="not-content border-border my-6 rounded-lg border p-4">
<summary class="cursor-pointer font-medium">Phase and reset details</summary>
<div class="text-muted-foreground mt-3 space-y-3 text-sm">
<p>
  A successful or blocked submission attempt switches the whole Form to
  <code>revalidationTiming</code>, including Fields registered later. The phase does not change
  after a Field's first blur or first validation.
</p>
<p>
  Native reset or a full <code>resetValidation()</code> returns the Form to its before-submit
  phase. Changing a timing prop is prospective: it does not clear submission state, validation
  results, or already revealed errors.
</p>
</div>
</details>

<details class="not-content border-border my-6 rounded-lg border p-4">
<summary class="cursor-pointer font-medium">Coming from TanStack Form</summary>
<p class="text-muted-foreground mt-3 text-sm">
Conceptually, <code>validationTiming</code> is Starwind's before-submit mode and
<code>revalidationTiming</code> is its mode after submission. Starwind separately controls
sticky error revelation, ARIA descriptions, summaries, and invalid-field focus.
</p>
</details>

### Try a policy

Choose the before-submit trigger, its after-submit replacement, and the independent reveal trigger.
Form-level values apply to every Field unless that Field overrides one of them.

<FrameworkCodeSwitcher>
  <div slot="astro">
```astro
---
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/starwind/select";

const timings = ["submit", "blur", "change", "manual"] as const;
---

<div class="w-full max-w-lg" data-policy-demo>
  <div class="mb-6 grid gap-4 sm:grid-cols-2">
    <div class="space-y-2">
      <label class="text-sm font-medium" for="policy-validation">Before submit</label>
      <Select
        defaultValue="submit"
        data-policy-group="validation"
        data-policy-attribute="data-validation-timing"
      >
        <SelectTrigger id="policy-validation" class="w-full">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {timings.map((timing) => (
            <SelectItem value={timing}>
              {timing[0]?.toUpperCase() + timing.slice(1)}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
    <div class="space-y-2">
      <label class="text-sm font-medium" for="policy-revalidation">After submit</label>
      <Select
        defaultValue="change"
        data-policy-group="revalidation"
        data-policy-attribute="data-revalidation-timing"
      >
        <SelectTrigger id="policy-revalidation" class="w-full">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {timings.map((timing) => (
            <SelectItem value={timing}>
              {timing[0]?.toUpperCase() + timing.slice(1)}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
    <div class="space-y-2">
      <label class="text-sm font-medium" for="policy-visibility">Show errors</label>
      <Select
        defaultValue="submit"
        data-policy-group="visibility"
        data-policy-attribute="data-error-visibility"
      >
        <SelectTrigger id="policy-visibility" class="w-full">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {timings.map((timing) => (
            <SelectItem value={timing}>
              {timing[0]?.toUpperCase() + timing.slice(1)}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  </div>

  <Form
    class="grid gap-5"
    validationTiming="submit"
    revalidationTiming="change"
    errorVisibility="submit"
  >
    <FormErrorSummary aria-label="Account errors">
      <p class="font-medium">Review the highlighted fields.</p>
    </FormErrorSummary>
    <FieldGroup>
      <Field name="email">
        <FieldLabel>Email</FieldLabel>
        <FieldControl type="email" required placeholder="ada@example.com" />
        <FieldDescription>Try submitting an empty or incomplete address.</FieldDescription>
        <FieldError match="valueMissing">Enter your email address.</FieldError>
        <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
      </Field>
      <Field
        name="workspace"
        validationTiming="change"
        revalidationTiming="blur"
        errorVisibility="change"
      >
        <FieldLabel>Override workspace</FieldLabel>
        <FieldControl required minlength="3" placeholder="acme" />
        <FieldDescription>
          Validates on change before submit, then only on blur afterward.
        </FieldDescription>
        <FieldError match="valueMissing">Choose a workspace name.</FieldError>
        <FieldError match="tooShort">Use at least three characters.</FieldError>
      </Field>
    </FieldGroup>

    <div class="flex flex-wrap items-center gap-3">
      <Button type="submit">Create account</Button>
      <Button type="reset" variant="ghost">Reset</Button>
    </div>
  </Form>

  <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
    Before submit: submit. After submit: change. Errors: submit.
  </output>
</div>

<script>
  const demo = document.querySelector<HTMLElement>("[data-policy-demo]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");

  const renderPolicy = (message?: string) => {
    if (!form || !output) return;
    const validation = form.getAttribute("data-validation-timing") ?? "submit";
    const revalidation = form.getAttribute("data-revalidation-timing") ?? "change";
    const visibility = form.getAttribute("data-error-visibility") ?? "submit";
    output.value =
      message ??
      `Before submit: ${validation}. After submit: ${revalidation}. Errors: ${visibility}.`;
  };

  demo?.querySelectorAll<HTMLElement>("[data-policy-attribute]").forEach((control) => {
    control.addEventListener("starwind:value-change", (event) => {
      const { value } = (event as CustomEvent<{ value: string | null }>).detail;
      const attribute = control.dataset.policyAttribute;
      if (value && attribute) {
        form?.setAttribute(attribute, value);
        renderPolicy();
      }
    });
  });

  if (form) {
    form.addEventListener("submit", (event) => {
      event.preventDefault();
      renderPolicy(`Submitted ${String(new FormData(form).get("email") ?? "")}`);
    });
    form.addEventListener("reset", () => window.setTimeout(() => renderPolicy(), 0));
  }
</script>
```
  </div>
  <div slot="react">
```tsx
import { useState } from "react";
import type { FormValidationTiming } from "@starwind-ui/react/form";
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/starwind/select";

const timings = ["submit", "blur", "change", "manual"] as const;

function TimingSelect({
  id,
  label,
  value,
  onValueChange,
}: {
  id: string;
  label: string;
  value: FormValidationTiming;
  onValueChange: (value: FormValidationTiming) => void;
}) {
  return (
    <div className="space-y-2">
      <label className="text-sm font-medium" htmlFor={id}>
        {label}
      </label>
      <Select
        value={value}
        onValueChange={(nextValue) => {
          if (nextValue) onValueChange(nextValue as FormValidationTiming);
        }}
      >
        <SelectTrigger id={id} className="w-full">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {timings.map((timing) => (
            <SelectItem key={timing} value={timing}>
              {timing[0]?.toUpperCase() + timing.slice(1)}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
}

export function Example() {
  const [validationTiming, setValidationTiming] =
    useState<FormValidationTiming>("submit");
  const [revalidationTiming, setRevalidationTiming] =
    useState<FormValidationTiming>("change");
  const [errorVisibility, setErrorVisibility] =
    useState<FormValidationTiming>("submit");

  return (
    <div className="w-full max-w-lg">
      <div className="mb-6 grid gap-4 sm:grid-cols-2">
        <TimingSelect
          id="policy-validation"
          label="Before submit"
          value={validationTiming}
          onValueChange={setValidationTiming}
        />
        <TimingSelect
          id="policy-revalidation"
          label="After submit"
          value={revalidationTiming}
          onValueChange={setRevalidationTiming}
        />
        <TimingSelect
          id="policy-visibility"
          label="Show errors"
          value={errorVisibility}
          onValueChange={setErrorVisibility}
        />
      </div>

      <Form
        className="grid gap-5"
        validationTiming={validationTiming}
        revalidationTiming={revalidationTiming}
        errorVisibility={errorVisibility}
        onSubmit={(event) => event.preventDefault()}
      >
        <FormErrorSummary aria-label="Account errors">
          <p className="font-medium">Review the highlighted fields.</p>
        </FormErrorSummary>
        <FieldGroup>
          <Field name="email">
            <FieldLabel>Email</FieldLabel>
            <FieldControl type="email" required placeholder="ada@example.com" />
            <FieldDescription>Try submitting an empty or incomplete address.</FieldDescription>
            <FieldError match="valueMissing">Enter your email address.</FieldError>
            <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
          </Field>
          <Field
            name="workspace"
            validationTiming="change"
            revalidationTiming="blur"
            errorVisibility="change"
          >
            <FieldLabel>Override workspace</FieldLabel>
            <FieldControl required minLength={3} placeholder="acme" />
            <FieldDescription>
              Validates on change before submit, then only on blur afterward.
            </FieldDescription>
            <FieldError match="tooShort">Use at least three characters.</FieldError>
          </Field>
        </FieldGroup>
        <div className="flex flex-wrap items-center gap-3">
          <Button type="submit">Create account</Button>
          <Button type="reset" variant="ghost">Reset</Button>
        </div>
      </Form>
      <output className="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        Before submit: {validationTiming}. After submit: {revalidationTiming}. Errors:{" "}
        {errorVisibility}.
      </output>
    </div>
  );
}
```
  </div>
  <div slot="vue">
```vue
<script setup lang="ts">
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/starwind/select";
import { computed, ref } from "vue";
import type { FormValidationTiming } from "@starwind-ui/vue/form";

const timings = ["submit", "blur", "change", "manual"] as const;

const validation = ref<FormValidationTiming>("submit");
const revalidation = ref<FormValidationTiming>("change");
const visibility = ref<FormValidationTiming>("submit");
const submitted = ref("");
const policy = computed(
  () =>
    `Before submit: ${validation.value}. After submit: ${revalidation.value}. Errors: ${visibility.value}.`,
);
function submit(event: Event) {
  submitted.value = `Submitted ${String(new FormData(event.target as HTMLFormElement).get("email") ?? "")}`;
}
</script>

<template>
  <div class="w-full max-w-lg" data-policy-demo>
    <div class="mb-6 grid gap-4 sm:grid-cols-2">
      <div class="space-y-2">
        <label class="text-sm font-medium" for="policy-validation">Before submit</label>
        <Select
          :model-value="validation"
          @value-change="
            (value) => {
              if (value) validation = value as FormValidationTiming;
              submitted = '';
            }
          "
          data-policy-group="validation"
          data-policy-attribute="data-validation-timing"
        >
          <SelectTrigger id="policy-validation" class="w-full">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <template v-for="timing in timings" :key="timing">
              <SelectItem :value="timing">
                {{ timing[0]?.toUpperCase() + timing.slice(1) }}
              </SelectItem>
            </template>
          </SelectContent>
        </Select>
      </div>
      <div class="space-y-2">
        <label class="text-sm font-medium" for="policy-revalidation">After submit</label>
        <Select
          :model-value="revalidation"
          @value-change="
            (value) => {
              if (value) revalidation = value as FormValidationTiming;
              submitted = '';
            }
          "
          data-policy-group="revalidation"
          data-policy-attribute="data-revalidation-timing"
        >
          <SelectTrigger id="policy-revalidation" class="w-full">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <template v-for="timing in timings" :key="timing">
              <SelectItem :value="timing">
                {{ timing[0]?.toUpperCase() + timing.slice(1) }}
              </SelectItem>
            </template>
          </SelectContent>
        </Select>
      </div>
      <div class="space-y-2">
        <label class="text-sm font-medium" for="policy-visibility">Show errors</label>
        <Select
          :model-value="visibility"
          @value-change="
            (value) => {
              if (value) visibility = value as FormValidationTiming;
              submitted = '';
            }
          "
          data-policy-group="visibility"
          data-policy-attribute="data-error-visibility"
        >
          <SelectTrigger id="policy-visibility" class="w-full">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <template v-for="timing in timings" :key="timing">
              <SelectItem :value="timing">
                {{ timing[0]?.toUpperCase() + timing.slice(1) }}
              </SelectItem>
            </template>
          </SelectContent>
        </Select>
      </div>
    </div>

    <Form
      @submit.prevent="submit"
      @reset="submitted = ''"
      class="grid gap-5"
      :validation-timing="validation"
      :revalidation-timing="revalidation"
      :error-visibility="visibility"
    >
      <FormErrorSummary aria-label="Account errors">
        <p class="font-medium">Review the highlighted fields.</p>
      </FormErrorSummary>
      <FieldGroup>
        <Field name="email">
          <FieldLabel>Email</FieldLabel>
          <FieldControl type="email" required placeholder="ada@example.com" />
          <FieldDescription>Try submitting an empty or incomplete address.</FieldDescription>
          <FieldError match="valueMissing">Enter your email address.</FieldError>
          <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
        </Field>
        <Field
          name="workspace"
          validationTiming="change"
          revalidationTiming="blur"
          errorVisibility="change"
        >
          <FieldLabel>Override workspace</FieldLabel>
          <FieldControl required minlength="3" placeholder="acme" />
          <FieldDescription>
            Validates on change before submit, then only on blur afterward.
          </FieldDescription>
          <FieldError match="valueMissing">Choose a workspace name.</FieldError>
          <FieldError match="tooShort">Use at least three characters.</FieldError>
        </Field>
      </FieldGroup>

      <div class="flex flex-wrap items-center gap-3">
        <Button type="submit">Create account</Button>
        <Button type="reset" variant="ghost">Reset</Button>
      </div>
    </Form>

    <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
      {{ submitted || policy }}
    </output>
  </div>
</template>
```
  </div>
</FrameworkCodeSwitcher>

### Validation and error revelation

Validation computes errors; revelation determines whether those errors participate in visible
messages, descriptions, and the Form summary. A hidden error can set `aria-invalid`, but it does not
enter `aria-describedby` or `FormErrorSummary` until its Field is revealed.

Reveal eligibility is sticky. Becoming valid hides the current message but does not make the Field
“unrevealed”; a later error can appear immediately. Native reset, full `resetValidation()`, Field
removal, or `setErrorsVisible(false)` removes that eligibility.

<div class="not-content my-6 grid gap-3 sm:grid-cols-2">
<article class="border-border rounded-lg border p-4">
<h4 class="mb-2 font-mono text-sm">form.checkValidity()</h4>
<p class="text-muted-foreground text-sm">
  Runs native and synchronous custom validation without revealing errors, moving focus, or
  changing the Form phase.
</p>
</article>
<article class="border-border rounded-lg border p-4">
<h4 class="mb-2 font-mono text-sm">form.reportValidity()</h4>
<p class="text-muted-foreground text-sm">
  Runs synchronous validation, reveals every checked invalid Field, and focuses the first one
  without changing the Form phase.
</p>
</article>
<article class="border-border rounded-lg border p-4">
<h4 class="mb-2 font-medium">Submit attempt</h4>
<p class="text-muted-foreground text-sm">
  Runs native, synchronous custom, and managed async validation. It applies each Field's error
  policy, focuses the first invalid Field, and enters the after-submit phase.
</p>
</article>
<article class="border-border rounded-lg border p-4">
<h4 class="mb-2 font-mono text-sm">formApi.validate()</h4>
<p class="text-muted-foreground text-sm">
  Runs the complete validator pipeline with optional focus and revelation, returning one
  coherent async outcome without changing the Form phase.
</p>
</article>
</div>

## Solve a common problem

Choose the task that matches the form you are building. Only one complete recipe is shown at a
time; each recipe includes Astro and React code.

<DocsTabs defaultValue="custom" class="my-6">
<DocsTabsList class="grid grid-cols-2 gap-1 before:hidden sm:flex sm:gap-0 sm:before:block">
<DocsTabsTrigger value="custom">Custom rules</DocsTabsTrigger>
<DocsTabsTrigger value="async">Async checks</DocsTabsTrigger>
<DocsTabsTrigger value="server">Server errors</DocsTabsTrigger>
<DocsTabsTrigger value="schema">Zod</DocsTabsTrigger>
<DocsTabsTrigger value="manual">Wizard step</DocsTabsTrigger>
</DocsTabsList>

<DocsTabsContent value="manual">

### Validate a wizard step

Retrieve the idempotent Form controller with `createForm(formElement)`. Use targeted names for a
wizard step or section, and opt into focus or unconditional revelation when the workflow calls for
it. Duplicate names target every matching Field; unknown names are ignored.

> **Info:** Submission always validates every registered Field. Manual timing disables matching interaction
validation; it does not allow an invalid Form to submit.

<FrameworkCodeSwitcher>
  <div slot="astro">
```astro
---
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
---

<div class="w-full max-w-lg" data-manual-validation>
  <Form
    validationTiming="manual"
    revalidationTiming="manual"
    errorVisibility="manual"
    class="grid gap-5"
  >
    <FormErrorSummary aria-label="Profile errors">
      <p class="font-medium">Review the highlighted fields.</p>
    </FormErrorSummary>

    <FieldGroup>
      <Field name="email">
        <FieldLabel>Email</FieldLabel>
        <FieldControl type="email" required value="not-an-email" />
        <FieldDescription>
          Validation runs only when one of the buttons requests it.
        </FieldDescription>
        <FieldError match="valueMissing">Enter your email address.</FieldError>
        <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
      </Field>

      <Field name="displayName">
        <FieldLabel>Display name</FieldLabel>
        <FieldControl required minlength="3" value="Ada" />
        <FieldError match="valueMissing">Enter a display name.</FieldError>
        <FieldError match="tooShort">Use at least three characters.</FieldError>
      </Field>
    </FieldGroup>

    <div class="flex flex-wrap items-center gap-2">
      <Button type="button" size="sm" data-action="validate-all">Validate all</Button>
      <Button type="button" size="sm" variant="outline" data-action="validate-email">
        Validate email + focus
      </Button>
      <Button type="button" size="sm" variant="outline" data-action="hide">
        Hide errors
      </Button>
      <Button type="button" size="sm" variant="ghost" data-action="reset">
        Reset validation
      </Button>
    </div>
  </Form>

  <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
    Change the values, then run validation.
  </output>
</div>

<script>
  import {
    createForm,
    type FormValidationOutcome,
    type FormValues,
  } from "@starwind-ui/astro/form";

  const read = (value: FormValues[string] | null | undefined) =>
    Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");

  const describeOutcome = (label: string, outcome: FormValidationOutcome) => {
    if (outcome.status === "aborted") return `${label}: replaced by newer validation.`;
    return `${label}: ${outcome.valid ? "valid" : `${outcome.errors.length} error(s)`}.`;
  };

  const demo = document.querySelector<HTMLElement>("[data-manual-validation]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");

  if (demo && form && output) {
    const formApi = createForm(form);

    demo.querySelector('[data-action="validate-all"]')?.addEventListener("click", async () => {
      output.value = describeOutcome("Full validation", await formApi.validate());
    });

    demo.querySelector('[data-action="validate-email"]')?.addEventListener("click", async () => {
      const outcome = await formApi.validate({
        names: ["email"],
        reveal: true,
        focus: true,
      });
      output.value = describeOutcome("Email validation", outcome);
    });

    demo.querySelector('[data-action="hide"]')?.addEventListener("click", () => {
      formApi.setErrorsVisible(false);
      output.value = "Errors hidden without clearing their validation results.";
    });

    demo.querySelector('[data-action="reset"]')?.addEventListener("click", () => {
      formApi.resetValidation();
      const values = new FormData(form);
      output.value =
        `Validation reset; values preserved: ${read(values.get("email"))}, ` +
        `${read(values.get("displayName"))}.`;
    });
  }
</script>
```
  </div>
  <div slot="react">
```tsx
import {
  createForm,
  type FormValidationOutcome,
  type FormValues,
} from "@starwind-ui/react/form";
import { useRef, useState } from "react";
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";

const read = (value: FormValues[string] | null | undefined) =>
  Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");

const describeOutcome = (label: string, outcome: FormValidationOutcome) => {
  if (outcome.status === "aborted") return `${label}: replaced by newer validation.`;
  return `${label}: ${outcome.valid ? "valid" : `${outcome.errors.length} error(s)`}.`;
};

export function Example() {
  const formRef = useRef<HTMLFormElement>(null);
  const [result, setResult] = useState("Change the values, then run validation.");

  const getFormApi = () => {
    const form = formRef.current;
    return form ? createForm(form) : null;
  };

  return (
    <div className="w-full max-w-lg">
      <Form
        ref={formRef}
        className="grid gap-5"
        validationTiming="manual"
        revalidationTiming="manual"
        errorVisibility="manual"
      >
        <FormErrorSummary aria-label="Profile errors">
          <p className="font-medium">Review the highlighted fields.</p>
        </FormErrorSummary>

        <FieldGroup>
          <Field name="email">
            <FieldLabel>Email</FieldLabel>
            <FieldControl type="email" required defaultValue="not-an-email" />
            <FieldDescription>
              Validation runs only when one of the buttons requests it.
            </FieldDescription>
            <FieldError match="valueMissing">Enter your email address.</FieldError>
            <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
          </Field>

          <Field name="displayName">
            <FieldLabel>Display name</FieldLabel>
            <FieldControl required minLength={3} defaultValue="Ada" />
            <FieldError match="valueMissing">Enter a display name.</FieldError>
            <FieldError match="tooShort">Use at least three characters.</FieldError>
          </Field>
        </FieldGroup>

        <div className="flex flex-wrap items-center gap-2">
          <Button
            type="button"
            size="sm"
            onClick={async () => {
              const outcome = await getFormApi()?.validate();
              if (outcome) setResult(describeOutcome("Full validation", outcome));
            }}
          >
            Validate all
          </Button>
          <Button
            type="button"
            size="sm"
            variant="outline"
            onClick={async () => {
              const outcome = await getFormApi()?.validate({
                names: ["email"],
                reveal: true,
                focus: true,
              });
              if (outcome) setResult(describeOutcome("Email validation", outcome));
            }}
          >
            Validate email + focus
          </Button>
          <Button
            type="button"
            size="sm"
            variant="outline"
            onClick={() => {
              getFormApi()?.setErrorsVisible(false);
              setResult("Errors hidden without clearing their validation results.");
            }}
          >
            Hide errors
          </Button>
          <Button
            type="button"
            size="sm"
            variant="ghost"
            onClick={() => {
              getFormApi()?.resetValidation();
              const values = formRef.current
                ? new FormData(formRef.current)
                : new FormData();
              setResult(
                `Validation reset; values preserved: ${read(values.get("email"))}, ${read(values.get("displayName"))}.`,
              );
            }}
          >
            Reset validation
          </Button>
        </div>
      </Form>

      <output
        className="text-muted-foreground mt-3 block text-sm"
        aria-live="polite"
      >
        {result}
      </output>
    </div>
  );
}
```
  </div>
  <div slot="vue">
```vue
<script setup lang="ts">
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
import { createForm, type FormValidationOutcome, type FormValues } from "@starwind-ui/vue/form";
import { onMounted, ref } from "vue";

const example = ref<HTMLDivElement | null>(null);
// Form owns Runtime initialization and disposal. This updates that shared instance.
onMounted(() => {
  const scope = example.value;
  if (!scope) return;
  const read = (value: FormValues[string] | null | undefined) =>
    Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");

  const describeOutcome = (label: string, outcome: FormValidationOutcome) => {
    if (outcome.status === "aborted") return `${label}: replaced by newer validation.`;
    return `${label}: ${outcome.valid ? "valid" : `${outcome.errors.length} error(s)`}.`;
  };

  const demo = scope.querySelector<HTMLElement>("[data-manual-validation]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");

  if (demo && form && output) {
    const formApi = createForm(form);

    demo.querySelector('[data-action="validate-all"]')?.addEventListener("click", async () => {
      output.value = describeOutcome("Full validation", await formApi.validate());
    });

    demo.querySelector('[data-action="validate-email"]')?.addEventListener("click", async () => {
      const outcome = await formApi.validate({
        names: ["email"],
        reveal: true,
        focus: true,
      });
      output.value = describeOutcome("Email validation", outcome);
    });

    demo.querySelector('[data-action="hide"]')?.addEventListener("click", () => {
      formApi.setErrorsVisible(false);
      output.value = "Errors hidden without clearing their validation results.";
    });

    demo.querySelector('[data-action="reset"]')?.addEventListener("click", () => {
      formApi.resetValidation();
      const values = new FormData(form);
      output.value =
        `Validation reset; values preserved: ${read(values.get("email"))}, ` +
        `${read(values.get("displayName"))}.`;
    });
  }
});
</script>

<template>
  <div ref="example">
    <div class="w-full max-w-lg" data-manual-validation>
      <Form
        validationTiming="manual"
        revalidationTiming="manual"
        errorVisibility="manual"
        class="grid gap-5"
      >
        <FormErrorSummary aria-label="Profile errors">
          <p class="font-medium">Review the highlighted fields.</p>
        </FormErrorSummary>

        <FieldGroup>
          <Field name="email">
            <FieldLabel>Email</FieldLabel>
            <FieldControl type="email" required default-value="not-an-email" />
            <FieldDescription>
              Validation runs only when one of the buttons requests it.
            </FieldDescription>
            <FieldError match="valueMissing">Enter your email address.</FieldError>
            <FieldError match="typeMismatch">Enter a valid email address.</FieldError>
          </Field>

          <Field name="displayName">
            <FieldLabel>Display name</FieldLabel>
            <FieldControl required minlength="3" default-value="Ada" />
            <FieldError match="valueMissing">Enter a display name.</FieldError>
            <FieldError match="tooShort">Use at least three characters.</FieldError>
          </Field>
        </FieldGroup>

        <div class="flex flex-wrap items-center gap-2">
          <Button type="button" size="sm" data-action="validate-all">Validate all</Button>
          <Button type="button" size="sm" variant="outline" data-action="validate-email">
            Validate email + focus
          </Button>
          <Button type="button" size="sm" variant="outline" data-action="hide">
            Hide errors
          </Button>
          <Button type="button" size="sm" variant="ghost" data-action="reset">
            Reset validation
          </Button>
        </div>
      </Form>

      <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        Change the values, then run validation.
      </output>
    </div>
  </div>
</template>
```
  </div>
</FrameworkCodeSwitcher>

`validate()` defaults to no focus. With no `reveal` option it applies each checked Field's
`errorVisibility` to the `manual` cause; `reveal: true` forces invalid checked Fields to show, while
`reveal: false` adds no new reveal eligibility. Starting newer validation, resetting intersecting
Fields, or destroying the Form aborts older work, which resolves with `status: "aborted"`.

`setErrorsVisible(true, names)` and `setErrorsVisible(false, names)` change presentation without
clearing errors or changing the Form phase. `resetValidation()` clears validation and reveal state
without resetting values or dirty/touched state; native `form.reset()` also restores native control
values and returns the Form to its before-submit phase.

</DocsTabsContent>

<DocsTabsContent value="custom" defaultVisible={true}>

### Add custom rules

Add domain rules without replacing native constraints. Field validators handle one value, form
validators compare values, and the managed submit callback only runs after every rule passes.

Without a Runtime `onSubmit` option, Starwind blocks invalid submission and lets a valid native
submission continue. Supplying `onSubmit` to `createForm()` activates managed submission: Runtime
prevents the native submit, runs synchronous and configured async validators, then calls the handler
with `values`, registered `fields`, the `submitter`, and the original event. A JSX `onSubmit` prop is
an ordinary DOM callback and does not by itself activate the managed async path.

<FrameworkCodeSwitcher>
  <div slot="astro">
```astro
---
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form } from "@/components/starwind/form";
---

<div class="w-full max-w-lg" data-project-form>
  <Form
    validationTiming="blur"
    revalidationTiming="change"
    errorVisibility="blur"
    class="grid gap-5"
  >
    <FieldGroup>
      <Field name="workspace">
        <FieldLabel>Workspace slug</FieldLabel>
        <FieldControl required value="acme" />
        <FieldError match="valueMissing">Enter a workspace slug.</FieldError>
      </Field>
      <Field name="project">
        <FieldLabel>Project slug</FieldLabel>
        <FieldControl required value="acme" />
        <FieldDescription>Try “admin” or the workspace slug.</FieldDescription>
        <FieldError match="valueMissing">Enter a project slug.</FieldError>
        <FieldError match="customError" messageSource="validation">
          Choose a different project slug.
        </FieldError>
      </Field>
    </FieldGroup>
    <div class="flex flex-wrap items-center gap-3">
      <Button type="submit">Create project</Button>
      <Button type="reset" variant="ghost">Reset</Button>
    </div>
  </Form>
  <output
    class="text-muted-foreground mt-3 block text-sm whitespace-pre-wrap"
    aria-live="polite"
  >
    Submit to run the custom rules.
  </output>
</div>

<script>
  import { createForm, type FormValues } from "@starwind-ui/astro/form";

  const demo = document.querySelector<HTMLElement>("[data-project-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");
  const read = (value: FormValues[string] | null | undefined) =>
    Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");

  if (form) {
    createForm(form, {
      fieldValidators: {
        project: (value) =>
          read(value).toLowerCase() === "admin"
            ? "The admin project slug is reserved."
            : null,
      },
      formValidators: (values) =>
        read(values.workspace) === read(values.project)
          ? { project: "Project slug must differ from the workspace slug." }
          : null,
      onSubmit: ({ values }) => {
        if (output) output.value = `Created ${read(values.workspace)}/${read(values.project)}`;
      },
    });

    form.addEventListener("reset", () => {
      window.setTimeout(() => {
        if (output) output.value = "Submit to run the custom rules.";
      }, 0);
    });
  }
</script>
```
  </div>
  <div slot="react">
```tsx
import { createForm, type FormValues } from "@starwind-ui/react/form";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form } from "@/components/starwind/form";

const read = (value: FormValues[string] | null | undefined) =>
  Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");

export function Example() {
  const formRef = useRef<HTMLFormElement>(null);
  const [result, setResult] = useState("Submit to run the custom rules.");

  useEffect(() => {
    const form = formRef.current;
    if (!form) return;

    createForm(form, {
      fieldValidators: {
        project: (value) =>
          read(value).toLowerCase() === "admin"
            ? "The admin project slug is reserved."
            : null,
      },
      formValidators: (values) =>
        read(values.workspace) === read(values.project)
          ? { project: "Project slug must differ from the workspace slug." }
          : null,
      onSubmit: ({ values }) => {
        setResult(`Created ${read(values.workspace)}/${read(values.project)}`);
      },
    });
  }, []);

  return (
    <div className="w-full max-w-lg">
      <Form
        ref={formRef}
        className="grid gap-5"
        validationTiming="blur"
        revalidationTiming="change"
        errorVisibility="blur"
        onReset={() => setResult("Submit to run the custom rules.")}
      >
        <FieldGroup>
          <Field name="workspace">
            <FieldLabel>Workspace slug</FieldLabel>
            <FieldControl required defaultValue="acme" />
            <FieldError match="valueMissing">Enter a workspace slug.</FieldError>
          </Field>
          <Field name="project">
            <FieldLabel>Project slug</FieldLabel>
            <FieldControl required defaultValue="acme" />
            <FieldDescription>Try “admin” or the workspace slug.</FieldDescription>
            <FieldError match="valueMissing">Enter a project slug.</FieldError>
            <FieldError match="customError" messageSource="validation">
              Choose a different project slug.
            </FieldError>
          </Field>
        </FieldGroup>
        <div className="flex flex-wrap items-center gap-3">
          <Button type="submit">Create project</Button>
          <Button type="reset" variant="ghost">Reset</Button>
        </div>
      </Form>
      <output
        className="text-muted-foreground mt-3 block text-sm whitespace-pre-wrap"
        aria-live="polite"
      >
        {result}
      </output>
    </div>
  );
}
```
  </div>
  <div slot="vue">
```vue
<script setup lang="ts">
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form } from "@/components/starwind/form";
import { createForm, type FormValues } from "@starwind-ui/vue/form";
import { onMounted, ref } from "vue";

const example = ref<HTMLDivElement | null>(null);
// Form owns Runtime initialization and disposal. This updates that shared instance.
onMounted(() => {
  const scope = example.value;
  if (!scope) return;
  const demo = scope.querySelector<HTMLElement>("[data-project-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");
  const read = (value: FormValues[string] | null | undefined) =>
    Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");

  if (form) {
    createForm(form, {
      fieldValidators: {
        project: (value) =>
          read(value).toLowerCase() === "admin" ? "The admin project slug is reserved." : null,
      },
      formValidators: (values) =>
        read(values.workspace) === read(values.project)
          ? { project: "Project slug must differ from the workspace slug." }
          : null,
      onSubmit: ({ values }) => {
        if (output) output.value = `Created ${read(values.workspace)}/${read(values.project)}`;
      },
    });

    form.addEventListener("reset", () => {
      window.setTimeout(() => {
        if (output) output.value = "Submit to run the custom rules.";
      }, 0);
    });
  }
});
</script>

<template>
  <div ref="example">
    <div class="w-full max-w-lg" data-project-form>
      <Form
        validationTiming="blur"
        revalidationTiming="change"
        errorVisibility="blur"
        class="grid gap-5"
      >
        <FieldGroup>
          <Field name="workspace">
            <FieldLabel>Workspace slug</FieldLabel>
            <FieldControl required default-value="acme" />
            <FieldError match="valueMissing">Enter a workspace slug.</FieldError>
          </Field>
          <Field name="project">
            <FieldLabel>Project slug</FieldLabel>
            <FieldControl required default-value="acme" />
            <FieldDescription>Try “admin” or the workspace slug.</FieldDescription>
            <FieldError match="valueMissing">Enter a project slug.</FieldError>
            <FieldError match="customError" messageSource="validation">
              Choose a different project slug.
            </FieldError>
          </Field>
        </FieldGroup>
        <div class="flex flex-wrap items-center gap-3">
          <Button type="submit">Create project</Button>
          <Button type="reset" variant="ghost">Reset</Button>
        </div>
      </Form>
      <output
        class="text-muted-foreground mt-3 block text-sm whitespace-pre-wrap"
        aria-live="polite"
      >
        Submit to run the custom rules.
      </output>
    </div>
  </div>
</template>
```
  </div>
</FrameworkCodeSwitcher>

</DocsTabsContent>

<DocsTabsContent value="async">

### Check availability asynchronously

Async validators receive an `AbortSignal`, so stale checks can be cancelled as accepted values
change. This example uses the default submit-then-change lifecycle: submit once to check
availability, then accepted corrections run the debounced validator. Pending or failed validation
blocks managed submission.

<FrameworkCodeSwitcher>
  <div slot="astro">
```astro
---
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
  FieldValidity,
} from "@/components/starwind/field";
import { Form } from "@/components/starwind/form";
---

<div class="w-full max-w-lg" data-handle-form>
  <Form
    validationTiming="submit"
    revalidationTiming="change"
    errorVisibility="submit"
    class="grid gap-5"
  >
    <Field name="handle">
      <FieldLabel>Team handle</FieldLabel>
      <FieldControl required autocomplete="off" placeholder="your-team" />
      <FieldDescription>
        Try “starwind”, “admin”, “taken”, or your own handle.
      </FieldDescription>
      <FieldError match="valueMissing">Enter a team handle.</FieldError>
      <FieldError match="customError" messageSource="validation">
        That handle is unavailable.
      </FieldError>
      <FieldValidity match="valid">Handle is available.</FieldValidity>
    </Field>
    <div class="flex flex-wrap items-center gap-3">
      <Button type="submit">Reserve handle</Button>
      <Button type="reset" variant="ghost">Reset</Button>
    </div>
  </Form>
  <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
    Submit once to check availability; accepted changes then recheck it.
  </output>
</div>

<script>
  import { createForm } from "@starwind-ui/astro/form";

  const unavailable = new Set(["admin", "starwind", "taken"]);
  const demo = document.querySelector<HTMLElement>("[data-handle-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");

  if (form) {
    createForm(form, {
      asyncFieldValidators: {
        handle: async (value, { signal }) => {
          const handle = String(value ?? "").trim().toLowerCase();
          if (!handle) return null;
          if (output) output.value = `Checking “${handle}”…`;

          await new Promise((resolve) => window.setTimeout(resolve, 400));
          if (signal.aborted) return null;

          const error = unavailable.has(handle) ? `“${handle}” is already reserved.` : null;
          if (output) output.value = error ?? `“${handle}” is available.`;
          return error;
        },
      },
      asyncValidationDebounceMs: 250,
      onSubmit: ({ values }) => {
        if (output) output.value = `Reserved @${String(values.handle ?? "")}`;
      },
    });

    form.addEventListener("reset", () => {
      window.setTimeout(() => {
        if (output) {
          output.value =
            "Submit once to check availability; accepted changes then recheck it.";
        }
      }, 0);
    });
  }
</script>
```
  </div>
  <div slot="react">
```tsx
import { createForm } from "@starwind-ui/react/form";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
  FieldValidity,
} from "@/components/starwind/field";
import { Form } from "@/components/starwind/form";

const unavailable = new Set(["admin", "starwind", "taken"]);

export function Example() {
  const formRef = useRef<HTMLFormElement>(null);
  const [result, setResult] = useState(
    "Submit once to check availability; accepted changes then recheck it.",
  );

  useEffect(() => {
    const form = formRef.current;
    if (!form) return;

    createForm(form, {
      asyncFieldValidators: {
        handle: async (value, { signal }) => {
          const handle = String(value ?? "").trim().toLowerCase();
          if (!handle) return null;
          setResult(`Checking “${handle}”…`);

          await new Promise((resolve) => window.setTimeout(resolve, 400));
          if (signal.aborted) return null;

          const error = unavailable.has(handle) ? `“${handle}” is already reserved.` : null;
          setResult(error ?? `“${handle}” is available.`);
          return error;
        },
      },
      asyncValidationDebounceMs: 250,
      onSubmit: ({ values }) => setResult(`Reserved @${String(values.handle ?? "")}`),
    });
  }, []);

  return (
    <div className="w-full max-w-lg">
      <Form
        ref={formRef}
        className="grid gap-5"
        validationTiming="submit"
        revalidationTiming="change"
        errorVisibility="submit"
        onReset={() =>
          setResult(
            "Submit once to check availability; accepted changes then recheck it.",
          )
        }
      >
        <Field name="handle">
          <FieldLabel>Team handle</FieldLabel>
          <FieldControl required autoComplete="off" placeholder="your-team" />
          <FieldDescription>
            Try “starwind”, “admin”, “taken”, or your own handle.
          </FieldDescription>
          <FieldError match="valueMissing">Enter a team handle.</FieldError>
          <FieldError match="customError" messageSource="validation">
            That handle is unavailable.
          </FieldError>
          <FieldValidity match="valid">Handle is available.</FieldValidity>
        </Field>
        <div className="flex flex-wrap items-center gap-3">
          <Button type="submit">Reserve handle</Button>
          <Button type="reset" variant="ghost">Reset</Button>
        </div>
      </Form>
      <output className="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        {result}
      </output>
    </div>
  );
}
```
  </div>
  <div slot="vue">
```vue
<script setup lang="ts">
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
  FieldValidity,
} from "@/components/starwind/field";
import { Form } from "@/components/starwind/form";
import { createForm } from "@starwind-ui/vue/form";
import { onMounted, ref } from "vue";

const example = ref<HTMLDivElement | null>(null);
// Form owns Runtime initialization and disposal. This updates that shared instance.
onMounted(() => {
  const scope = example.value;
  if (!scope) return;
  const unavailable = new Set(["admin", "starwind", "taken"]);
  const demo = scope.querySelector<HTMLElement>("[data-handle-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");

  if (form) {
    createForm(form, {
      asyncFieldValidators: {
        handle: async (value, { signal }) => {
          const handle = String(value ?? "")
            .trim()
            .toLowerCase();
          if (!handle) return null;
          if (output) output.value = `Checking “${handle}”…`;

          await new Promise((resolve) => window.setTimeout(resolve, 400));
          if (signal.aborted) return null;

          const error = unavailable.has(handle) ? `“${handle}” is already reserved.` : null;
          if (output) output.value = error ?? `“${handle}” is available.`;
          return error;
        },
      },
      asyncValidationDebounceMs: 250,
      onSubmit: ({ values }) => {
        if (output) output.value = `Reserved @${String(values.handle ?? "")}`;
      },
    });

    form.addEventListener("reset", () => {
      window.setTimeout(() => {
        if (output) {
          output.value = "Submit once to check availability; accepted changes then recheck it.";
        }
      }, 0);
    });
  }
});
</script>

<template>
  <div ref="example">
    <div class="w-full max-w-lg" data-handle-form>
      <Form
        validationTiming="submit"
        revalidationTiming="change"
        errorVisibility="submit"
        class="grid gap-5"
      >
        <Field name="handle">
          <FieldLabel>Team handle</FieldLabel>
          <FieldControl required autocomplete="off" placeholder="your-team" />
          <FieldDescription>
            Try “starwind”, “admin”, “taken”, or your own handle.
          </FieldDescription>
          <FieldError match="valueMissing">Enter a team handle.</FieldError>
          <FieldError match="customError" messageSource="validation">
            That handle is unavailable.
          </FieldError>
          <FieldValidity match="valid">Handle is available.</FieldValidity>
        </Field>
        <div class="flex flex-wrap items-center gap-3">
          <Button type="submit">Reserve handle</Button>
          <Button type="reset" variant="ghost">Reset</Button>
        </div>
      </Form>
      <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        Submit once to check availability; accepted changes then recheck it.
      </output>
    </div>
  </div>
</template>
```
  </div>
</FrameworkCodeSwitcher>

</DocsTabsContent>

<DocsTabsContent value="server">

### Show server and external errors

Use `setExternalErrors()` for server responses and other errors computed outside the current
validation run. External errors mark matching Fields invalid without entering the post-submit
phase or moving focus.

<FrameworkCodeSwitcher>
  <div slot="astro">
```astro
---
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
---

<div class="w-full max-w-lg" data-server-error-form>
  <Form class="grid gap-5">
    <FormErrorSummary aria-label="Account errors">
      <p class="font-medium">Review the highlighted fields.</p>
    </FormErrorSummary>
    <Field name="email">
      <FieldLabel>Email</FieldLabel>
      <FieldControl type="email" required value="ada@example.com" />
      <FieldDescription>Edit the email to clear the server error.</FieldDescription>
      <FieldError match="customError" messageSource="validation">
        That email is already registered.
      </FieldError>
    </Field>
    <div class="flex flex-wrap items-center gap-2">
      <Button type="button" size="sm" data-show-server-error>Show server error</Button>
      <Button type="button" size="sm" variant="ghost" data-clear-server-error>
        Clear server error
      </Button>
    </div>
  </Form>
  <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
    Apply a simulated server response.
  </output>
</div>

<script>
  import { createForm } from "@starwind-ui/astro/form";

  const demo = document.querySelector<HTMLElement>("[data-server-error-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");
  if (form && output) {
    const formApi = createForm(form, {
      externalErrorsOnReset: "clear",
    });
    const errors = {
      email: {
        key: "customError" as const,
        message: "That email is already registered.",
        source: "server" as const,
      },
    };

    form.querySelector("[data-show-server-error]")?.addEventListener("click", () => {
      formApi.setExternalErrors(errors, {
        clearOnChange: true,
        visibility: "immediate",
      });
      output.value = "Immediate server error shown. Edit the email to clear it.";
    });

    form.querySelector("[data-clear-server-error]")?.addEventListener("click", () => {
      formApi.clearExternalErrors("email");
      output.value = "External email errors cleared.";
    });
  }
</script>
```
  </div>
  <div slot="react">
```tsx
import { createForm } from "@starwind-ui/react/form";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";

const serverErrors = {
  email: {
    key: "customError" as const,
    message: "That email is already registered.",
    source: "server" as const,
  },
};

export function Example() {
  const formRef = useRef<HTMLFormElement>(null);
  const formApiRef = useRef<ReturnType<typeof createForm> | null>(null);
  const [result, setResult] = useState("Apply a simulated server response.");

  useEffect(() => {
    if (formRef.current) {
      formApiRef.current = createForm(formRef.current);
    }
  }, []);

  return (
    <div className="w-full max-w-lg">
      <Form ref={formRef} className="grid gap-5">
        <FormErrorSummary aria-label="Account errors">
          <p className="font-medium">Review the highlighted fields.</p>
        </FormErrorSummary>
        <Field name="email">
          <FieldLabel>Email</FieldLabel>
          <FieldControl type="email" required defaultValue="ada@example.com" />
          <FieldDescription>Edit the email to clear the server error.</FieldDescription>
          <FieldError match="customError" messageSource="validation">
            That email is already registered.
          </FieldError>
        </Field>
        <div className="flex flex-wrap items-center gap-2">
          <Button
            type="button"
            size="sm"
            onClick={() => {
              formApiRef.current?.setExternalErrors(serverErrors, {
                clearOnChange: true,
              });
              setResult("Immediate server error shown. Edit the email to clear it.");
            }}
          >
            Show server error
          </Button>
          <Button
            type="button"
            size="sm"
            variant="ghost"
            onClick={() => {
              formApiRef.current?.clearExternalErrors("email");
              setResult("External email errors cleared.");
            }}
          >
            Clear server error
          </Button>
        </div>
      </Form>
      <output className="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        {result}
      </output>
    </div>
  );
}
```
  </div>
  <div slot="vue">
```vue
<script setup lang="ts">
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
import { createForm } from "@starwind-ui/vue/form";
import { onMounted, ref } from "vue";

const example = ref<HTMLDivElement | null>(null);
// Form owns Runtime initialization and disposal. This updates that shared instance.
onMounted(() => {
  const scope = example.value;
  if (!scope) return;
  const demo = scope.querySelector<HTMLElement>("[data-server-error-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");
  if (form && output) {
    const formApi = createForm(form, {
      externalErrorsOnReset: "clear",
    });
    const errors = {
      email: {
        key: "customError" as const,
        message: "That email is already registered.",
        source: "server" as const,
      },
    };

    form.querySelector("[data-show-server-error]")?.addEventListener("click", () => {
      formApi.setExternalErrors(errors, {
        clearOnChange: true,
        visibility: "immediate",
      });
      output.value = "Immediate server error shown. Edit the email to clear it.";
    });

    form.querySelector("[data-clear-server-error]")?.addEventListener("click", () => {
      formApi.clearExternalErrors("email");
      output.value = "External email errors cleared.";
    });
  }
});
</script>

<template>
  <div ref="example">
    <div class="w-full max-w-lg" data-server-error-form>
      <Form class="grid gap-5">
        <FormErrorSummary aria-label="Account errors">
          <p class="font-medium">Review the highlighted fields.</p>
        </FormErrorSummary>
        <Field name="email">
          <FieldLabel>Email</FieldLabel>
          <FieldControl type="email" required default-value="ada@example.com" />
          <FieldDescription>Edit the email to clear the server error.</FieldDescription>
          <FieldError match="customError" messageSource="validation">
            That email is already registered.
          </FieldError>
        </Field>
        <div class="flex flex-wrap items-center gap-2">
          <Button type="button" size="sm" data-show-server-error>Show server error</Button>
          <Button type="button" size="sm" variant="ghost" data-clear-server-error>
            Clear server error
          </Button>
        </div>
      </Form>
      <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        Apply a simulated server response.
      </output>
    </div>
  </div>
</template>
```
  </div>
</FrameworkCodeSwitcher>

`visibility` defaults to `immediate`. Use `visibility: "policy"` to store the error while preserving
the Field's current reveal eligibility; a later matching policy event can reveal it.
`clearOnChange` clears the matching external error on the next accepted semantic change.

Native reset clears external errors by default. Configure
`createForm(form, { externalErrorsOnReset: "preserve" })` to retain them, or override one validation
reset with `resetValidation({ externalErrors: "clear" | "preserve" })`. Preserved external errors
keep their prior reveal eligibility while unrelated reveal state is cleared.

</DocsTabsContent>

<DocsTabsContent value="schema">

### Validate with Zod

Starwind does not require a schema library. A small parser adapter maps Zod issues to matching
fields, then `createFormSchemaValidator` runs that parser through the same validation and submission
pipeline. Install Zod in your app before using this example.

```bash
pnpm add zod
```

<FrameworkCodeSwitcher>
  <div slot="astro">
```astro
---
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
---

<div class="w-full max-w-lg" data-zod-form>
  <Form class="grid gap-5">
    <FormErrorSummary aria-label="Project errors">
      <p class="font-medium">Review the project details.</p>
    </FormErrorSummary>
    <FieldGroup>
      <Field name="workEmail">
        <FieldLabel>Work email</FieldLabel>
        <FieldControl type="email" value="ada@personal.test" />
        <FieldDescription>Use an @example.com address.</FieldDescription>
        <FieldError match="customError" messageSource="validation">
          Enter a valid work email.
        </FieldError>
      </Field>
      <Field name="projectKey">
        <FieldLabel>Project key</FieldLabel>
        <FieldControl value="sw" />
        <FieldDescription>
          Use four or more lowercase letters, numbers, or dashes.
        </FieldDescription>
        <FieldError match="customError" messageSource="validation">
          Enter a valid project key.
        </FieldError>
      </Field>
    </FieldGroup>
    <div class="flex flex-wrap items-center gap-3">
      <Button type="submit">Create project</Button>
      <Button type="reset" variant="ghost">Reset</Button>
    </div>
  </Form>
  <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
    Submit to validate with Zod.
  </output>
</div>

<script>
  import { z } from "zod";
  import {
    createForm,
    createFormSchemaValidator,
    type FormSchemaResult,
    type FormValues,
  } from "@starwind-ui/astro/form";

  const schema = z.object({
    workEmail: z
      .email({ error: "Enter a valid email address." })
      .refine((value) => value.endsWith("@example.com"), {
        error: "Use your @example.com work email.",
      }),
    projectKey: z
      .string()
      .min(4, { error: "Use at least four characters." })
      .regex(/^[a-z0-9-]+$/, { error: "Use lowercase letters, numbers, or dashes." }),
  });

  const read = (value: FormValues[string] | undefined) =>
    Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");
  const parse = (values: FormValues): FormSchemaResult => {
    const result = schema.safeParse({
      workEmail: read(values.workEmail),
      projectKey: read(values.projectKey),
    });
    return result.success
      ? { success: true }
      : {
          success: false,
          issues: result.error.issues.map((issue) => ({
            message: issue.message,
            path: issue.path.map(String),
          })),
        };
  };

  const demo = document.querySelector<HTMLElement>("[data-zod-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");
  if (form) {
    createForm(form, {
      formValidators: createFormSchemaValidator(parse),
      onSubmit: ({ values }) => {
        if (output) output.value = `Created ${read(values.projectKey)}`;
      },
    });

    form.addEventListener("reset", () => {
      window.setTimeout(() => {
        if (output) output.value = "Submit to validate with Zod.";
      }, 0);
    });
  }
</script>
```
  </div>
  <div slot="react">
```tsx
import { z } from "zod";
import {
  createForm,
  createFormSchemaValidator,
  type FormSchemaResult,
  type FormValues,
} from "@starwind-ui/react/form";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";

const schema = z.object({
  workEmail: z
    .email({ error: "Enter a valid email address." })
    .refine((value) => value.endsWith("@example.com"), {
      error: "Use your @example.com work email.",
    }),
  projectKey: z
    .string()
    .min(4, { error: "Use at least four characters." })
    .regex(/^[a-z0-9-]+$/, { error: "Use lowercase letters, numbers, or dashes." }),
});

const read = (value: FormValues[string] | undefined) =>
  Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");

const parse = (values: FormValues): FormSchemaResult => {
  const result = schema.safeParse({
    workEmail: read(values.workEmail),
    projectKey: read(values.projectKey),
  });
  return result.success
    ? { success: true }
    : {
        success: false,
        issues: result.error.issues.map((issue) => ({
          message: issue.message,
          path: issue.path.map(String),
        })),
      };
};

export function Example() {
  const formRef = useRef<HTMLFormElement>(null);
  const [result, setResult] = useState("Submit to validate with Zod.");

  useEffect(() => {
    const form = formRef.current;
    if (!form) return;
    createForm(form, {
      formValidators: createFormSchemaValidator(parse),
      onSubmit: ({ values }) => setResult(`Created ${read(values.projectKey)}`),
    });
  }, []);

  return (
    <div className="w-full max-w-lg">
      <Form
        ref={formRef}
        className="grid gap-5"
        onReset={() => setResult("Submit to validate with Zod.")}
      >
        <FormErrorSummary aria-label="Project errors">
          <p className="font-medium">Review the project details.</p>
        </FormErrorSummary>
        <FieldGroup>
          <Field name="workEmail">
            <FieldLabel>Work email</FieldLabel>
            <FieldControl type="email" defaultValue="ada@personal.test" />
            <FieldDescription>Use an @example.com address.</FieldDescription>
            <FieldError match="customError" messageSource="validation">
              Enter a valid work email.
            </FieldError>
          </Field>
          <Field name="projectKey">
            <FieldLabel>Project key</FieldLabel>
            <FieldControl defaultValue="sw" />
            <FieldDescription>
              Use four or more lowercase letters, numbers, or dashes.
            </FieldDescription>
            <FieldError match="customError" messageSource="validation">
              Enter a valid project key.
            </FieldError>
          </Field>
        </FieldGroup>
        <div className="flex flex-wrap items-center gap-3">
          <Button type="submit">Create project</Button>
          <Button type="reset" variant="ghost">Reset</Button>
        </div>
      </Form>
      <output className="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        {result}
      </output>
    </div>
  );
}
```
  </div>
  <div slot="vue">
```vue
<script setup lang="ts">
import { Button } from "@/components/starwind/button";
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/starwind/field";
import { Form, FormErrorSummary } from "@/components/starwind/form";
import { z } from "zod";
import {
  createForm,
  createFormSchemaValidator,
  type FormSchemaResult,
  type FormValues,
} from "@starwind-ui/vue/form";
import { onMounted, ref } from "vue";

const example = ref<HTMLDivElement | null>(null);
// Form owns Runtime initialization and disposal. This updates that shared instance.
onMounted(() => {
  const scope = example.value;
  if (!scope) return;
  const schema = z.object({
    workEmail: z
      .email({ error: "Enter a valid email address." })
      .refine((value) => value.endsWith("@example.com"), {
        error: "Use your @example.com work email.",
      }),
    projectKey: z
      .string()
      .min(4, { error: "Use at least four characters." })
      .regex(/^[a-z0-9-]+$/, {
        error: "Use lowercase letters, numbers, or dashes.",
      }),
  });

  const read = (value: FormValues[string] | undefined) =>
    Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");
  const parse = (values: FormValues): FormSchemaResult => {
    const result = schema.safeParse({
      workEmail: read(values.workEmail),
      projectKey: read(values.projectKey),
    });
    return result.success
      ? { success: true }
      : {
          success: false,
          issues: result.error.issues.map((issue) => ({
            message: issue.message,
            path: issue.path.map(String),
          })),
        };
  };

  const demo = scope.querySelector<HTMLElement>("[data-zod-form]");
  const form = demo?.querySelector<HTMLFormElement>("form");
  const output = demo?.querySelector<HTMLOutputElement>("output");
  if (form) {
    createForm(form, {
      formValidators: createFormSchemaValidator(parse),
      onSubmit: ({ values }) => {
        if (output) output.value = `Created ${read(values.projectKey)}`;
      },
    });

    form.addEventListener("reset", () => {
      window.setTimeout(() => {
        if (output) output.value = "Submit to validate with Zod.";
      }, 0);
    });
  }
});
</script>

<template>
  <div ref="example">
    <div class="w-full max-w-lg" data-zod-form>
      <Form class="grid gap-5">
        <FormErrorSummary aria-label="Project errors">
          <p class="font-medium">Review the project details.</p>
        </FormErrorSummary>
        <FieldGroup>
          <Field name="workEmail">
            <FieldLabel>Work email</FieldLabel>
            <FieldControl type="email" default-value="ada@personal.test" />
            <FieldDescription>Use an @example.com address.</FieldDescription>
            <FieldError match="customError" messageSource="validation">
              Enter a valid work email.
            </FieldError>
          </Field>
          <Field name="projectKey">
            <FieldLabel>Project key</FieldLabel>
            <FieldControl default-value="sw" />
            <FieldDescription>
              Use four or more lowercase letters, numbers, or dashes.
            </FieldDescription>
            <FieldError match="customError" messageSource="validation">
              Enter a valid project key.
            </FieldError>
          </Field>
        </FieldGroup>
        <div class="flex flex-wrap items-center gap-3">
          <Button type="submit">Create project</Button>
          <Button type="reset" variant="ghost">Reset</Button>
        </div>
      </Form>
      <output class="text-muted-foreground mt-3 block text-sm" aria-live="polite">
        Submit to validate with Zod.
      </output>
    </div>
  </div>
</template>
```
  </div>
</FrameworkCodeSwitcher>

</DocsTabsContent>
</DocsTabs>

## Accessibility

Validation and presentation stay synchronized across native and Runtime-backed controls:

- Invalid checked Fields receive `aria-invalid` even when their messages are still hidden.
- Only revealed errors participate in `aria-describedby` and `FormErrorSummary`.
- `reportValidity()`, blocked submission, and `validate({ focus: true })` focus the first applicable
invalid Field in DOM order; `checkValidity()` and default `validate()` do not move focus.
- Summary entries remain in DOM order and keep duplicate-name Field roots distinct. Activating an
entry focuses its Field control.
- Focus moving among descendants of one composite Field does not count as blur.

Keep `FormErrorSummary` explicit so its placement, heading, and surrounding instructions match the
product. Field-level timing props can override the owning Form independently, but omitting them
keeps the policy easier to understand and maintain.

## API Reference
### Form
| Prop | Type | Required | Default | Kind | Description |
| --- | --- | --- | --- | --- | --- |
| `errorVisibility` | `"blur" \| "change" \| "manual" \| "submit"` | No | `"submit"` | Primitive override | Selects whether semantic change, blur, submit, or manual validation reveals errors; defaults to submit. |
| `revalidationTiming` | `"blur" \| "change" \| "manual" \| "submit"` | No | `"change"` | Primitive override | After a Form submission attempt, replaces validationTiming with semantic change, blur, submit, or manual validation; defaults to change. |
| `validationTiming` | `"blur" \| "change" \| "manual" \| "submit"` | No | `"submit"` | Primitive override | Selects semantic change, blur, submit, or manual validation before a Form submission attempt; defaults to submit. |
- Inherits form attributes.

### FormErrorSummary
- Inherits div attributes.
### Primitive And Runtime API
Behavior, state, events, form participation, and imperative methods are documented in the lower-level references.
- Primitive: [Form Primitive](/docs/primitives/form/)
- Runtime factory: [`createForm`](/docs/runtime/#create-form) from `@starwind-ui/runtime/form`

## Changelog

### v1.0.1

- Named the generated aggregate default export so React and Astro tooling can identify the installed component cleanly.

### v1.0.0

- Added Runtime-backed native form coordination and validation timing.
- See the [Form Primitive](/docs/primitives/form/) for the underlying unstyled anatomy and behavior API.