2

Form

Installation

pnpm dlx @mielui/svelte add form field fieldset input

Native first

Root renders a form and forwards native attributes, callbacks, and symbol-keyed attachments. It does not prevent submission, serialize values, swallow errors, or reset inputs. Use a native action, SvelteKit form action, remote form, or your own onsubmit handler. Native validation remains enabled unless you explicitly set novalidate.

<script lang="ts">
    import { Button } from '@mielui/svelte/components/button';
    import * as Field from '@mielui/svelte/components/field';
    import * as Fieldset from '@mielui/svelte/components/fieldset';
    import * as Form from '@mielui/svelte/components/form';
    import { Input } from '@mielui/svelte/components/input';
    import { onDestroy } from 'svelte';

    let pending = $state(false);
    let timer: ReturnType<typeof setTimeout> | undefined;
    let message = $state('');

    onDestroy(() => {
        clearTimeout(timer);
    });

    function submit(event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }) {
        event.preventDefault();
        if (pending) {
            return;
        }
        const data = new FormData(event.currentTarget, event.submitter);
        const name = String(data.get('displayName')).trim();
        pending = true;
        message = 'Checking profile…';
        timer = setTimeout(() => {
            pending = false;
            message = `${name} is ready to save. This preview keeps your data in the browser.`;
        }, 800);
    }
</script>

<Form.Root class="w-full max-w-md" onsubmit={submit} {pending}>
    <Fieldset.Root>
        <Fieldset.Legend>Profile details</Fieldset.Legend>
        <Field.Group>
            <Field.Root required>
                <Field.Label>Display name</Field.Label>
                <Field.Control>
                    {#snippet children(control)}
                        <Input
                            {...control}
                            name="displayName"
                            autocomplete="name"
                            placeholder="Sam Rivera"
                        />
                    {/snippet}
                </Field.Control>
                <Field.Description>The name shown to your teammates.</Field.Description>
            </Field.Root>
            <Field.Root required>
                <Field.Label>Work email</Field.Label>
                <Field.Control>
                    {#snippet children(control)}
                        <Input
                            {...control}
                            type="email"
                            name="email"
                            autocomplete="email"
                            placeholder="sam@company.com"
                        />
                    {/snippet}
                </Field.Control>
            </Field.Root>
        </Field.Group>
    </Fieldset.Root>
    <Form.Actions>
        <Form.Submit>Save profile</Form.Submit>
        <Button type="reset" variant="ghost">Reset</Button>
    </Form.Actions>
    <p role="status" class="text-sm text-foreground-muted">{message}</p>
</Form.Root>

Actions is an independent flex container; move it before the fields or omit it entirely. Submit is a native submit button styled with Button and inherits Root’s pending state. It preserves name, value, formaction, formmethod, and other submitter attributes. Status is a polite live region with neutral, success, and error tones. Keep it mounted when its message changes.

Pass pending as a boolean or a pending-request count. It marks the form busy and shows loading feedback on Submit without disabling all fields or removing their values from FormData. Root’s element binding exposes the actual form. Use element.requestSubmit() to preserve validation and submitter behavior; element.submit() bypasses them.

A complete error summary

Pass validation issues directly to ErrorSummary. Each issue needs a message and may include a path or controlId. Paths resolve against native field names within the form; form-level issues remain text. Nested paths use names such as profile.email or members[0].email. After a submission finishes with issues, the summary receives focus. Set focusOnError=false when your application manages focus. Initial server-rendered errors do not steal focus. Override heading or children(issues) to customize the content.

SvelteKit remote forms

This live example uses SvelteKit’s experimental remote-form API. Schema errors appear beside each control. A reserved username is rejected on the server. Validation and network failures keep the draft.

The docs application opts in to remote functions and async compilation. Mielui itself imports no Kit runtime and requires no experimental flag. To run this example in your own Kit 2 application, explicitly enable both options and install Valibot (pnpm add valibot). These examples target Kit 2.70 or later and Svelte 5.39 or later. See theofficial remote-functions guide before enabling experimental features.

const config = {
  compilerOptions: { experimental: { async: true } },
  kit: { experimental: { remoteFunctions: true } }
};

export default config;

Validate at the server boundary

Place the handler in a .remote.ts file under src, outside src/lib/server. Its schema is the authority. Real mutations must authenticate and authorize the current request before changing data. This demonstration waits briefly to make pending feedback visible and returns a validation result; it writes nothing.

import { invalid } from '@sveltejs/kit';
import { form } from '$app/server';
import * as v from 'valibot';

export const validateProfile = form(
    v.object({
        username: v.pipe(
            v.string(),
            v.trim(),
            v.minLength(3, 'Use at least 3 characters.'),
            v.maxLength(40, 'Use at most 40 characters.')
        ),
        email: v.pipe(v.string(), v.email('Enter a valid email address.')),
        intent: v.picklist(['validate', 'validate-reset'])
    }),
    async ({ username, intent }, issue) => {
        await new Promise((resolve) => setTimeout(resolve, 350));
        if (username.toLowerCase() === 'admin') {
            invalid(issue.username('This username is reserved. Choose another one.'));
        }
        if (username.toLowerCase() === 'system') {
            invalid('This profile cannot be validated right now. Choose another demo username.');
        }
        return { username, intent };
    }
);

Preserve the remote form’s attributes

Spread the remote form or its enhance() result directly onto Root. Its attachment reaches the actual form. Spread fields.username.as('text') onto Input after Field.Control’s attributes, so Kit owns the field name, value, and validity. Do not add a competing bind:value or replace its name. Each rendered form needs its own instance; .for(id) keeps repeated forms independent.

enhance receives a form instance. Await instance.submit(): false means validation failed; thrown errors are transport or application failures. A successful enhanced submission does not reset automatically. Call instance.element.reset() deliberately after success. The examples use toast.promise for pending, success, and failure feedback. Mount Toaster once in your app layout. ErrorSummary focuses the error summary after rendering its issues. The summary resolves issue paths to the form’s named fields; form-level issues remain readable text.

Use field.issues() for local feedback, fields.issues() for form-level issues, and fields.allIssues() for a full summary. Pass local issues to Field.Root and render Field.Error. Pass all issues to Form.ErrorSummary, including root-level issues without a path. Validation is not triggered on every keystroke by Form; choose validate() or preflight(schema) when your application needs that policy.

Files, checkboxes, and multiple actions

Remote files need enctype="multipart/form-data" and fields.file.as('file') on a native file control. Unchecked checkboxes are absent from FormData, so make boolean schema fields optional or defaulted. Mark sensitive fields with Kit’s underscore naming convention, such as _password, so validation round trips do not echo them. Use fields.intent.as('submit', value) on each Submit to preserve which action was chosen.

Multiple actions and reset

For server validation returned without JavaScript, declare stable describedBy and errorId values on Field.Control and matching IDs on Description and Error. The multiple-action example uses $props.id() so those relationships work before hydration. The basic example lets Field associate descriptions and errors after hydration.

Use submitter values when the server needs to distinguish actions. This example resets only after successful validation; errors preserve the draft.

Stable SvelteKit form actions

Ordinary form actions need no experimental flag. A named POST action continues to work without JavaScript. Add progressive enhancement with fromAction; the attachment passes through Root. Keep your +page.server.ts action responsible for validation and persistence.

<script lang="ts">
  import * as Form from '@mielui/svelte/components/form';
  import { enhance } from '$app/forms';
  import { fromAction } from 'svelte/attachments';
</script>

<Form.Root method="POST" action="?/save" {@attach fromAction(enhance)}>
  <!-- Compose fields here. -->
  <Form.Actions><Form.Submit>Save</Form.Submit></Form.Actions>
</Form.Root>

Layout and feedback ownership

Compose Fieldset for named groups and Field for individual controls. Root supplies a vertical layout that class can replace; class="contents" lets a dialog’s form fields and footer participate in the surrounding layout. Keep dialog headings outside that form when they belong to the dialog itself. There is no hidden validation store, submission timer, or global form state.

API reference

Form.Root

Prop Type Default
element Bindable HTMLFormElement | null | undefined —
pending number | boolean | undefined false

Form.Actions

Form.ErrorSummary

Prop Type Default
issues readonly Readonly<{ message: string; controlId?: string | undefined; path?: readonly (string | number)[] | undefined; }>[] | undefined []
focusOnError boolean | undefined true
heading Snippet<[]> | undefined —
children Snippet<[readonly Readonly<{ message: string; controlId?: string | undefined; path?: readonly (string | number)[] | undefined; }>[]]> | undefined —
element Bindable HTMLDivElement | undefined —

Form.Status

Prop Type Default
tone "error" | "success" | "neutral" | undefined 'neutral'

Form.Submit

Prop Type Default
children Snippet<[]> | undefined —
onkeydown ((event: KeyboardEvent) => void) | undefined —
onclick ((event: MouseEvent) => void) | undefined —
status ButtonStatus | undefined

Controlled visual state. Loading remains focusable and refuses activation.

—
disabled boolean | undefined —
variant ButtonVariant | undefined —
size "sm" | "md" | "lg" | "icon" | undefined —
element Bindable HTMLButtonElement | HTMLAnchorElement | undefined

Bindable reference to the rendered DOM element. Type is the union of the two possible element types -- narrow at the use site:

—
unstyled boolean | undefined

Skip the variant/size base classes and render with `class` alone.

—
loading boolean | undefined

Convenience alias for `status="loading"`.

—
loadingLabel string | undefined —
successLabel string | undefined —
errorLabel string | undefined —