Form
Import
Section titled “Import”import { Form } from '@delightstack/components';Types are also available:
import type { FormContext, StandardSchema } from '@delightstack/components';Basic Usage
Section titled “Basic Usage”View code
<script>import { Form, Input, Button } from '@delightstack/components';import { z } from 'zod';
let formData = $state({ email: '', password: '',});
const schema = z.object({ email: z.string().email('Invalid email'), password: z.string().min(8, 'At least 8 characters'),});</script>
<Form bind:data={formData} {schema} onsubmit={({ data }) => console.log(data)}><Input name="email" label="Email" type="email" /><Input name="password" label="Password" type="password" /><Button type="submit">Sign In</Button></Form>Examples
Section titled “Examples”Validation with Zod
Section titled “Validation with Zod”The schema prop accepts any Standard Schema compatible validator. Zod, Valibot, and ArkType all work. Fields with a name register themselves with the form and show their error once touched — no bind:value per field.
View code
<script> import { Form, Input, Select, Checkbox, Button, Fieldset } from '@delightstack/components'; import { z } from 'zod';
let formData = $state({ name: '', email: '', password: '', role: '', terms: false, });
const schema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), role: z.string().min(1, 'Please select a role'), terms: z.literal(true, { message: 'You must accept the terms' }), });
async function handleSubmit({ data }) { await api.createAccount(data); goto('/dashboard'); }</script>
<Form bind:data={formData} {schema} onsubmit={handleSubmit} validate_on="blur"> <Fieldset label="Create Account" bordered> <Input name="name" label="Name" /> <Input name="email" label="Email" type="email" /> <Input name="password" label="Password" type="password" /> <Select name="role" label="Role" placeholder="Select a role" options={[ { value: 'user', label: 'User' }, { value: 'admin', label: 'Admin' }, ]} /> <Checkbox name="terms" label="I accept the terms and conditions" /> </Fieldset>
<div style="display: flex; gap: 1rem;"> <Button type="reset" ghost>Reset</Button> <Button type="submit">Create Account</Button> </div></Form>Validation with Valibot
Section titled “Validation with Valibot”Any Standard Schema validator works — swapping Zod for Valibot needs no other changes.
View code
<script> import { Form, Input, Button } from '@delightstack/components'; import * as v from 'valibot';
let formData = $state({ name: '', age: '' });
const schema = v.object({ name: v.pipe(v.string(), v.minLength(2, 'At least 2 characters')), age: v.pipe(v.coerce(v.number(), Number), v.minValue(18, 'Must be 18+')), });</script>
<Form bind:data={formData} {schema} onsubmit={handleSubmit}> <Input name="name" label="Name" /> <Input name="age" label="Age" type="number" /> <Button type="submit">Submit</Button></Form>Promise-Aware Submission
Section titled “Promise-Aware Submission”If onsubmit returns a Promise, the form automatically sets is_submitting to true, disabling all fields and preventing double-submit. A submit <Button> inside the form picks this up too, showing a spinner until the Promise settles. Submit the demo below to watch the form lock while the (simulated 1.5s) request runs.
View code
<Form bind:data={formData} {schema} onsubmit={async ({ data }) => { await api.createAccount(data); goto('/dashboard'); }}> <Input name="email" label="Email" type="email" /> <Button type="submit">Submit</Button></Form>Validation Timing
Section titled “Validation Timing”Control when fields are validated with validate_on. Switch the mode in the demo below and type an invalid email to feel the difference.
Validates when you leave the field.
View code
<!-- Validate on blur (default) --><Form validate_on="blur" ...>
<!-- Validate on every keystroke --><Form validate_on="change" ...>
<!-- Only validate on submit --><Form validate_on="submit" ...>A native <button type="reset"> triggers form reset, clearing all values, errors, and touched states. With reset_on_submit, the form also clears itself after a successful submit.
View code
<Form bind:data={formData} {schema} reset_on_submit> <Input name="name" label="Name" /> <Input name="email" label="Email" type="email" /> <Button type="reset" ghost>Reset</Button> <Button type="submit">Submit</Button></Form>| Prop | Type | Default | Description |
|---|---|---|---|
data | object | {} | Form data object, bindable. Ignored when entity is set |
entity | FormEntity | — | An entity to bind the form to (e.g. db.entity(...)): values, dirty/submitting state, and save() on submit all derive from it |
schema | StandardSchema | - | Any Standard Schema compatible validator |
validate_on | 'change' | 'blur' | 'submit' | 'blur' | When to validate fields |
disabled | boolean | false | Disable all child fields |
reset_on_submit | boolean | false | Reset form after successful submission |
dense | boolean | false | Compact spacing between child fields |
comfortable | boolean | false | Relaxed spacing between child fields |
id | string | auto | Element ID |
class | string | '' | Additional CSS classes |
children | Snippet | - | Form content |
Events
Section titled “Events”| Event | Detail | Description |
|---|---|---|
onsubmit | { data, is_valid } | Form submitted. Can return a Promise for automatic loading state |
onchange | { data, errors } | Form data changed |
onerror | { errors, error? } | Validation failed on submit, or an entity save rejected |
onsaved | { entity } | An entity-backed form saved successfully |
onreset | - | Form was reset |
interface FormEntity { /** The editable draft the form reads & writes */ value: Record<string, unknown>; /** Persists the draft; called on submit after validation passes */ save: () => Promise<unknown>; readonly saving?: boolean; readonly has_changes?: boolean; reset?: () => void; readonly error?: unknown;}
interface FormContext { data: Record<string, unknown>; errors: Record<string, string>; touched: Record<string, boolean>; is_dirty: boolean; is_submitting: boolean; is_valid: boolean; disabled: boolean; validate_on: 'change' | 'blur' | 'submit'; register: ( name: string, element: HTMLElement, validator?: (value: unknown) => unknown, ) => void; unregister: (name: string) => void; setValue: (name: string, value: unknown) => void; getValue: (name: string) => unknown; setTouched: (name: string) => void; validateField: (name: string) => void;}
interface StandardSchema<Input = unknown, Output = Input> { readonly '~standard': { readonly version: 1; readonly vendor: string; readonly validate: ( value: unknown, ) => { value: Output } | { issues: Array<{ message: string; path?: Array<PropertyKey> }> }; };}How Context Works
Section titled “How Context Works”Child form controls (Input, Select, Checkbox, etc.) auto-register when they have a name prop and are inside a <Form>. The Form provides a FormContext via setContext, and child controls:
- Call
register(name, element, validator?)on mount — a field may pass a field-level validator (like theparsefunction from a database table’sform.fieldprops) - Read
errors[name]to display field-level errors - Read
touched[name]to know when to show errors - Call
setValue(name, value)when value changes - Call
setTouched(name)on blur - Call
unregister(name)on destroy
This means child controls work the same whether inside a Form or standalone.
Context-driven values: when a field (Input, Select, Checkbox, Toggle) has a name and no explicit value (or checked) prop, it reads its value from the form data via getValue(name) and writes through setValue(name, value) — no bind:value needed. Field names may be dot-notation paths (address.city), which resolve into nested data.
When fields register validators, the Form runs them alongside the form-level schema on the same validate_on timing. If both produce an error for the same field, the schema’s message wins; fields the schema does not cover keep their field-level error. A child Input never runs its parse prop itself while inside a Form, so the two layers cannot conflict.
Accessibility
Section titled “Accessibility”- Semantic
<form>element aria-live="polite"region for form-level errors- Field errors linked via
aria-describedby(handled by child controls) - Auto-focus on first error field on submit validation failure
scrollIntoView({ behavior: 'smooth', block: 'center' })for off-screen errorsaria-disabledwhen form is disabled