form.checkValidity()
Runs native and synchronous custom validation without revealing errors, moving focus, or changing the Form phase.
Starwind UI v3.0 is now available! Migration guide
---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>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> );}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.
pnpx starwind@latest add formnpx starwind@latest add formyarn dlx starwind@latest add formThe browser still owns submission and FormData. Use ordinary action, method, submit, and reset behavior; Runtime controls serialize through their installed hidden inputs.
Start with the defaults
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.
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.
1. Interact
A value changes, focus leaves a Field, the Form submits, or code requests validation.
2. Validate
The current before- or after-submit timing decides whether validators run.
3. Reveal
Error visibility independently decides whether existing errors may render.
4. Continue
Valid submission proceeds; invalid submission focuses the first invalid Field.
| 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 |
| 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 |
A successful or blocked submission attempt switches the whole Form to revalidationTiming, including Fields registered later. The phase does not change after a Field’s first blur or first validation.
Native reset or a full resetValidation() 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.
Conceptually, validationTiming is Starwind’s before-submit mode and revalidationTiming is its mode after submission. Starwind separately controls sticky error revelation, ARIA descriptions, summaries, and invalid-field focus.
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.
---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>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> );}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.
Runs native and synchronous custom validation without revealing errors, moving focus, or changing the Form phase.
Runs synchronous validation, reveals every checked invalid Field, and focuses the first one without changing the Form phase.
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.
Runs the complete validator pipeline with optional focus and revelation, returning one coherent async outcome without changing the Form phase.
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.
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.
Manual does not disable submission validation
Submission always validates every registered Field. Manual timing disables matching interaction validation; it does not allow an invalid Form to submit.
---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>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> );}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.
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.
---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>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> );}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.
---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>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> );}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.
---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>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> );}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.
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.
pnpm add zod---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>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> );}Validation and presentation stay synchronized across native and Runtime-backed controls:
aria-invalid even when their messages are still hidden.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.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.
Styled Component API
These props are added or materially changed by the installed styled component. Standard HTML attributes remain available through the inherited interfaces noted below. Expand a prop to see named type definitions and framework-specific imports. Follow the Primitive and Runtime links for lower-level behavior props.
Inherits form attributes.
Contains the following additional props:
errorVisibility "blur" | "change" | "manual" | "submit" "submit" revalidationTiming "blur" | "change" | "manual" | "submit" "change" validationTiming "blur" | "change" | "manual" | "submit" "submit" Primitive And Runtime API
Use these references when you need the lower-level behavior APIs behind Form.
Primitive API
Runtime API
createForm from @starwind-ui/runtime/form