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

npx sinaui add form

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.

Try The Canon of Medicine to see an error from the server.

<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 valibot

The 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.

Try The Canon of Medicine to see an error from the server.

<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-describedby and marks it aria-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.
Introduction
Theming
Changelog
Button
Action button
Confirm button
Copy button
Hold to confirm
Split button
Calendar
Checkbox
Color picker
Combobox
Date picker
Date range picker
File drop
Form
Input
Mention input
Multi select
Number field
OTP input
Password input
Phone input
Radio cards
Radio group
Rich text editor
Search field
Segmented control
Select
Signature pad
Slider
Switch
Tag input
Upload
Wheel picker
Textarea
Time field
Toggle group
Toolbar
Alert dialog
Command palette
Context menu
Dialog
Drawer
Dropdown
Hover card
Popover
Tooltip
Announcement bar
Alert
Empty state
Meter
Progress
Rolling number
Skeleton
Spinner
Toast
Avatar
Avatar group
Badge
Card
Carousel
Chart
Chat
Code block
Data table
Folder
Kbd
Mind map
Resizable panels
Scroll area
Separator
Sortable
Table
Tag group
Virtual list
Audio player
Image
Image compare
Video
Visualizer
Breadcrumb
File tree
Menubar
Navigation menu
Pagination
Sidebar layout
Stepper
Tabs
Alarm clock
Clock
Dynamic island
Fluid orb
Hourglass
Stopwatch
Timer
AI chat
AI media
Auth
Board
Booking
Dashboard
File explorer
Messaging
Notifications
Settings
Team
Overview
Pricing
Agent chat
Calendar
Voice agent
Mail inbox
CMS
Blog
Marketing site
E-commerce
AI agents
SaaS landing page
SaaS app
Accordion
Dark theme
Light theme