Forms
Form
Collects input and says clearly what needs fixing, at the right moment. Two versions: one with no dependencies, and one built on TanStack Form.
Installation
Which one?
- Simple for most forms. It uses the browser's own checks (
required,type="email",min,pattern) with your wording, and still validates if JavaScript hasn't loaded. - TanStack Form when the form holds typed state: a schema shared with the server, fields that depend on each other, arrays of fields, or async checks.
How errors behave
Both versions follow the same timing, so switching between them changes nothing for the person filling the form in:
- A field is checked when it's left after a change, never while typing and never when merely tabbed past.
- Once a field shows an error, the error disappears the moment it's fixed.
- Sending checks everything and moves focus to the first problem, where screen readers read the error with the field.
- Errors from your server appear on the right field. A failed request shows a plain message, never the raw error text.
Simple
Copy src/lib/ui/form.svelte.ts, plus the fields you use (Input.svelte, Select.svelte, Checkbox.svelte...). No dependencies.
Borrow a manuscript
Send it empty to see every error at once, and focus land on the first.
<script lang="ts">
import Alert from '#lib/ui/Alert.svelte';
import Button from '#lib/ui/Button.svelte';
import Checkbox from '#lib/ui/Checkbox.svelte';
import Input from '#lib/ui/Input.svelte';
import Select from '#lib/ui/Select.svelte';
import { createForm } from '#lib/ui/form.svelte.js';
let sent = $state<string>();
const form = createForm({
messages: {
name: { valueMissing: 'Enter your name.' },
email: {
valueMissing: 'Enter your email address.',
typeMismatch: 'Enter an email address like ali@baytalhikma.org.'
},
manuscript: { valueMissing: 'Choose a manuscript.' },
days: {
valueMissing: 'Enter how many days you need it.',
rangeUnderflow: 'Borrow it for at least 1 day.',
rangeOverflow: 'Loans last 90 days at most.'
},
rules: { valueMissing: 'Agree to the reading-room rules to borrow.' }
},
async onsubmit(data) {
// Stands in for your API; it answers with an error code the form maps to a message.
await new Promise((ok) => setTimeout(ok, 1000));
if (data.get('manuscript') === 'canon')
return { manuscript: 'Every copy of the Canon is out. Choose another manuscript.' };
sent = `We'll write to ${data.get('email')} when it's ready to collect.`;
}
});
</script>
<form class="form" {@attach form.attach}>
{#if form.errors.form}
<Alert tone="danger" title="The request wasn't sent" live>{form.errors.form}</Alert>
{/if}
{#if sent}<Alert tone="success" title="Request sent" live>{sent}</Alert>{/if}
<Input label="Name" name="name" autocomplete="name" required error={form.errors.name} />
<Input
label="Email"
name="email"
type="email"
autocomplete="email"
required
error={form.errors.email}
/>
<Select
label="Manuscript"
name="manuscript"
placeholder="Choose a manuscript"
required
hint="Try The Canon of Medicine to see an error from the server."
error={form.errors.manuscript}
>
<option value="canon">The Canon of Medicine</option>
<option value="optics">Book of Optics</option>
<option value="jabr">The Compendious Book on Calculation</option>
</Select>
<Input
label="Days"
name="days"
type="number"
inputmode="numeric"
min="1"
max="90"
value="40"
required
error={form.errors.days}
/>
<Checkbox name="rules" required error={form.errors.rules}>
I agree to the reading-room rules
</Checkbox>
<div class="actions">
<Button type="reset" variant="ghost" onclick={() => (sent = undefined)}>Clear</Button>
<Button type="submit" loading={form.submitting}>Send request</Button>
</div>
</form>
<style>
.form {
display: grid;
gap: 1rem;
inline-size: 100%;
max-inline-size: 26rem;
}
.actions {
display: flex;
flex-wrap: wrap-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
</style>createForm takes messages (your wording for each browser check, by field
name), an optional validate(data) for checks the browser can't do, and onsubmit(data), which may return errors by field name. Put form in that object
for a message about the whole form.
TanStack Form
Install TanStack Form, and a schema library if you want one (here, Valibot):
pnpm add @tanstack/svelte-form valibotThe same field components render every field.
Borrow a manuscript, with TanStack Form
The same form, with a Valibot schema as the single source of rules.
<script lang="ts">
import { createForm } from '@tanstack/svelte-form';
import { tick } from 'svelte';
import * as v from 'valibot';
import Alert from '#lib/ui/Alert.svelte';
import Button from '#lib/ui/Button.svelte';
import Checkbox from '#lib/ui/Checkbox.svelte';
import Input from '#lib/ui/Input.svelte';
import Select from '#lib/ui/Select.svelte';
const Loan = v.object({
name: v.pipe(v.string(), v.trim(), v.nonEmpty('Enter your name.')),
email: v.pipe(
v.string(),
v.nonEmpty('Enter your email address.'),
v.email('Enter an email address like ali@baytalhikma.org.')
),
manuscript: v.pipe(v.string(), v.nonEmpty('Choose a manuscript.')),
rules: v.pipe(v.boolean(), v.value(true, 'Agree to the reading-room rules to borrow.'))
});
let el: HTMLFormElement;
let sent = $state<string>();
const form = createForm(() => ({
defaultValues: { name: '', email: '', manuscript: '', rules: false },
validators: {
onChange: Loan,
// Stands in for your API; it answers with an error code the form maps to a message.
onSubmitAsync: async ({ value }) => {
await new Promise((ok) => setTimeout(ok, 1000));
if (value.manuscript === 'canon')
return {
fields: { manuscript: 'Every copy of the Canon is out. Choose another manuscript.' }
};
}
},
onSubmit: ({ value }) => {
sent = `We'll write to ${value.email} when it's ready to collect.`;
},
// Take the person to the first problem, as the simple version does.
onSubmitInvalid: async () => {
await tick();
el.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
}
}));
const submitting = form.useSelector((s) => s.isSubmitting);
const attempted = form.useSelector((s) => s.submissionAttempts > 0);
// Shown once the field has been left or the form sent: no errors while someone is still typing.
type Meta = { isBlurred: boolean; errors: unknown[] };
const errorOf = (meta: Meta) =>
meta.isBlurred || attempted.current
? meta.errors.map((e) => (typeof e === 'string' ? e : (e as { message: string }).message))[0]
: undefined;
</script>
<form
bind:this={el}
class="form"
novalidate
onsubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{#if sent}<Alert tone="success" title="Request sent" live>{sent}</Alert>{/if}
<form.Field name="name">
{#snippet children(field)}
<Input
label="Name"
name={field.name}
autocomplete="name"
value={field.state.value}
oninput={(e) => field.handleChange(e.currentTarget.value)}
onblur={field.handleBlur}
error={errorOf(field.state.meta)}
/>
{/snippet}
</form.Field>
<form.Field name="email">
{#snippet children(field)}
<Input
label="Email"
name={field.name}
type="email"
autocomplete="email"
value={field.state.value}
oninput={(e) => field.handleChange(e.currentTarget.value)}
onblur={field.handleBlur}
error={errorOf(field.state.meta)}
/>
{/snippet}
</form.Field>
<form.Field name="manuscript">
{#snippet children(field)}
<Select
label="Manuscript"
name={field.name}
placeholder="Choose a manuscript"
value={field.state.value}
onchange={(e) => field.handleChange(e.currentTarget.value)}
onblur={field.handleBlur}
hint="Try The Canon of Medicine to see an error from the server."
error={errorOf(field.state.meta)}
>
<option value="canon">The Canon of Medicine</option>
<option value="optics">Book of Optics</option>
<option value="jabr">The Compendious Book on Calculation</option>
</Select>
{/snippet}
</form.Field>
<form.Field name="rules">
{#snippet children(field)}
<Checkbox
name={field.name}
checked={field.state.value}
onchange={(e) => field.handleChange(e.currentTarget.checked)}
onblur={field.handleBlur}
error={errorOf(field.state.meta)}
>
I agree to the reading-room rules
</Checkbox>
{/snippet}
</form.Field>
<div class="actions">
<Button
type="button"
variant="ghost"
onclick={() => {
form.reset();
sent = undefined;
}}>Clear</Button
>
<Button type="submit" loading={submitting.current}>Send request</Button>
</div>
</form>
<style>
.form {
display: grid;
gap: 1rem;
inline-size: 100%;
max-inline-size: 26rem;
}
.actions {
display: flex;
flex-wrap: wrap-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
</style>Accessibility
- Each error is tied to its field with
aria-describedbyand marks itaria-invalid, so it's read out when the field is focused. - Invalid fields get a red outline and a red focus ring: the error is never shown by color alone, the message is always there too.
- The request's outcome is announced; the send button shows a spinner and ignores repeat presses while it waits.