Skip to content

Form

import { Form } from '@delightstack/components';

Types are also available:

import type { FormContext, StandardSchema } from '@delightstack/components';
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>

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.

Create Account
I accept the terms and conditions
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>

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>

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>

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>
PropTypeDefaultDescription
dataobject{}Form data object, bindable. Ignored when entity is set
entityFormEntityAn entity to bind the form to (e.g. db.entity(...)): values, dirty/submitting state, and save() on submit all derive from it
schemaStandardSchema-Any Standard Schema compatible validator
validate_on'change' | 'blur' | 'submit''blur'When to validate fields
disabledbooleanfalseDisable all child fields
reset_on_submitbooleanfalseReset form after successful submission
densebooleanfalseCompact spacing between child fields
comfortablebooleanfalseRelaxed spacing between child fields
idstringautoElement ID
classstring''Additional CSS classes
childrenSnippet-Form content
EventDetailDescription
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> }> };
};
}

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:

  1. Call register(name, element, validator?) on mount — a field may pass a field-level validator (like the parse function from a database table’s form.field props)
  2. Read errors[name] to display field-level errors
  3. Read touched[name] to know when to show errors
  4. Call setValue(name, value) when value changes
  5. Call setTouched(name) on blur
  6. 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.

  • 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 errors
  • aria-disabled when form is disabled