Form
A form wrapper that renders an accessible error summary and moves focus on failed submits, while staying out of the way of native submission and SvelteKit's use:enhance.
Import
import { Form, toFormErrors } from "@hyzer-labs/ui"Demo
The default path: omit onSubmit and the Form never touches the submit.
SvelteKit's use:enhance does the work (attached with fromAction, Svelte 5.32+), and toFormErrors maps the action's zod-flattened
errors straight into the summary. The demo is live on the real enhance wiring; only the server
hop is simulated, since this docs site is prerendered. It runs the same schema as the action
below, with about 400ms of latency. Submit it empty: the errors arrive asynchronously, and
the summary takes focus. On success the form resets, which is enhance's default.
<!-- +page.svelte -->
<script>
import { enhance } from '$app/forms';
import { fromAction } from 'svelte/attachments';
import { Form, TextInput, Button, toFormErrors } from '@hyzer-labs/ui';
let { form } = $props();
const errors = $derived(toFormErrors(form?.errors));
// Pending state for the submit button, tracked in the enhance submit hook.
let pending = $state(false);
const submit = () => {
pending = true;
return async ({ update }) => {
pending = false;
await update();
};
};
</script>
<Form method="POST" {errors} summaryHeadingLevel={3} ariaLabel="League signup" {@attach fromAction(enhance, () => submit)}>
<Stack gap="md">
<TextInput name="player" label="Player name" />
<TextInput name="email" label="Email" type="email" />
<Button type="submit" loading={pending}>Sign up</Button>
</Stack>
</Form>The action this demo simulates, with validation on the server where it belongs:
// +page.server.ts
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
const schema = z.object({
player: z.string().min(1, 'Enter your name.'),
email: z.email('Enter a valid email address.')
});
export const actions = {
default: async ({ request }) => {
const data = Object.fromEntries(await request.formData());
const result = schema.safeParse(data);
if (!result.success) return fail(400, { errors: z.flattenError(result.error) });
// save result.data, fully typed
return { success: true };
}
};The same schema on the client, live: onSubmit reads the native FormData (no bindings needed), safeParse validates, and toFormErrors feeds both the summary and the inline field errors from one
array. novalidate makes the summary the single error surface. Submit it empty.
import { z } from 'zod';
import { Form, TextInput, Button, toFormErrors } from '@hyzer-labs/ui';
const schema = z.object({
player: z.string().min(1, 'Enter your name.'),
email: z.email('Enter a valid email address.'),
holes: z.coerce.number().min(1, 'Enter at least 1 hole.').max(36, '36 holes max.')
});
let errors = $state<FormError[]>([]);
const fieldError = (name: string) => errors.find((e) => e.name === name)?.message;
function handleSubmit(e: SubmitEvent) {
const data = Object.fromEntries(new FormData(e.currentTarget as HTMLFormElement));
const result = schema.safeParse(data);
errors = result.success ? [] : toFormErrors(z.flattenError(result.error));
if (result.success) save(result.data); // fully typed
}
<Form {errors} onSubmit={handleSubmit} novalidate summaryHeadingLevel={3} ariaLabel="Round review">
<Stack gap="md">
<TextInput name="player" label="Player name" error={fieldError('player')} />
<TextInput name="email" label="Email" type="email" error={fieldError('email')} />
<TextInput name="holes" label="Holes played" type="number" inputmode="numeric" error={fieldError('holes')} />
<Button type="submit">Post review</Button>
</Stack>
</Form>Summary items are sorted by the fields' DOM order, not the array order. Here the email
error comes first in the array, but the name field comes first in the form. An error
whose name is empty, or does not match a field, becomes a form-level error: plain text,
listed last.
const errors: FormError[] = [
{ name: 'email', message: 'Enter a valid email address.' },
{ name: 'player', message: 'Enter your name.' },
{ name: '', message: 'Signups close at midnight. Unsaved entries are dropped.' }
];
<Form {errors} summaryHeadingLevel={3} ariaLabel="Summary anatomy">
…
</Form>focusTarget="firstField" skips the summary and puts focus straight on the first
invalid field. That suits short forms, where navigating a summary is more work than it saves.
Submit the empty form and watch where focus lands.
<Form {errors} onSubmit={handleSubmit} focusTarget="firstField" summaryHeadingLevel={3} ariaLabel="Report an ace">
<Stack gap="md">
<TextInput name="ace-hole" label="Hole number" bind:value={ace} required error={fieldError('ace-hole')} />
<Button type="submit">Report ace</Button>
</Stack>
</Form>Props
| Name | Type | Default | Note |
|---|---|---|---|
errors | FormError[] | [] | The summary renders when non-empty. See FormError below. |
onSubmit | (e: SubmitEvent) => void | — | Omit for native submission / use:enhance (the default path); provide it for client-side validation — preventDefault runs first. |
summaryTitle | string | ((count: number) => string) | 'There is a problem' | A string overrides the default outright; a function receives the error count. The default is count-aware and plural-safe: "There is a problem" for 1, "There are 3 problems" for 3. |
summaryHeadingLevel | 2 | 3 | 4 | 5 | 6 | 2 | Match the page's heading structure. |
focusTarget | 'summary' | 'firstField' | 'summary' | What receives focus after a submit with errors. |
novalidate | boolean | false | |
ariaLabel | string | — | Names the form landmark. |
children | Snippet | — | Required. The form content. |
class | string | — | Merged after the hz-form class. |
FormError
| Name | Type | Default | Note |
|---|---|---|---|
name | string | — | Field name to link to. Empty or unresolved names become non-interactive form-level errors, listed last. |
message | string | — | Required. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-form
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-state | 'error' | absent | Present once the summary has errors. Two-state, unlike the field family’s three — a form is never disabled. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-form-error-summary | on an Alert | The summary box — an Alert underneath. Load-bearing beyond styling: the focus-on-submit path queries this class. |
.hz-form-error-list | child element | The list of errors. |
.hz-form-error-summary-item | child element | One error row, including its jump link. |
Accessibility
The error summary is the first child of the form and has role="alert", so it is announced when it appears. It takes focus when errors arrive after a submit. With focusTarget="firstField", the first invalid field takes focus instead.
Each summary item links to its field and focuses it on activation, scrolling smoothly unless prefers-reduced-motion is set.
Set summaryHeadingLevel so the summary title fits your page's heading outline.
References: MDN: <form>