Forms

Form

Keep native submission intact, with clear feedback from the first field to the final action.

Installation

pnpm dlx @mielui/svelte add form field fieldset input

Usage

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 * as Field from '@mielui/svelte/components/field';
    import * as Form from '@mielui/svelte/components/form';
    import { Input } from '@mielui/svelte/components/input';

    let message = $state('');
</script>

<Form.Root method="POST" action="?/save">
    <Field.Root required>
        <Field.Label>Work email</Field.Label>
        <Field.Control>
            {#snippet children(control)}
                <Input {...control} type="email" name="email" />
            {/snippet}
        </Field.Control>
    </Field.Root>
    <Form.Actions>
        <Form.Submit>Save</Form.Submit>
    </Form.Actions>
    <Form.Status>{message}</Form.Status>
</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.

Profile form

A fieldset with two required fields. Submitting shows the pending state, then a status message.

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, such as profile.email or members[0].email; issues without one stay plain text. After a submission finishes with issues, the summary receives focus. Set focusOnError={false} when your application manages focus; errors rendered on the server do not take focus. Pass a heading snippet, or children that receives the issues, to customize the content. To translate the heading text, pass labels={{ heading: 'Vรฉrifiez vos informations' }}; every key of FormErrorSummaryLabels is optional and English is the fallback.

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 app, enable both options in the sveltekit() plugin in vite.config.ts and install Valibot (pnpm add valibot). These examples target Kit 3 and Svelte 5.57.1 or later. See the official remote-functions guide before enabling experimental features.

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [
        sveltekit({
            compilerOptions: {
                experimental: {
                    async: true
                }
            },
            experimental: {
                remoteFunctions: true
            }
        })
    ]
});

Validate at the server boundary

Place the handler in a .remote.ts file under src, outside any server directory. 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 * as v from 'valibot';
import { form } from '$app/server';

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 { enhance } from '$app/forms';
    import * as Form from '@mielui/svelte/components/form';
    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.

Status and submit states

Status prints a message for the whole form. tone colors it as error, success or neutral, which is the default.

Submit is built on Button and shows the form's progress by itself. Pass status to drive it by hand as idle, loading, success or error, with labels setting the text for each state. size changes its height, unstyled removes the Button classes, and onclick and onkeydown run before the form submits.

API reference

Form.Root

  • element Bindable
    HTMLFormElement | null

    Bindable reference to the DOM element.

  • pending
    number | boolean

    Marks the form as submitting. A number is the count of pending requests.

    Default false

Form.ErrorSummary

  • labels
    FormErrorSummaryLabels

    Overrides the built-in text and accessible names. Every key is optional and English is the fallback.

  • issues
    readonly Readonly<{ message: string; controlId?: string | undefined; path?: readonly (string | number)[] | undefined; }>[]

    Validation issues to summarize.

    Default []

  • focusOnError
    boolean

    Moves focus to the summary when issues appear.

    Default true

  • heading
    Snippet<[]>

    Replaces the summary heading.

  • children
    Snippet<[readonly Readonly<{ message: string; controlId?: string | undefined; path?: readonly (string | number)[] | undefined; }>[]]>

    Content rendered inside.

  • element Bindable
    HTMLDivElement

    Bindable reference to the DOM element.

Form.Status

  • tone
    "neutral" | "success" | "error"

    Color that carries the meaning.

    Default 'neutral'

Form.Submit

  • children
    Snippet<[]>

    Content rendered inside.

  • onkeydown
    ((event: KeyboardEvent) => void)

    Called on key down, before the button handles the key itself.

  • onclick
    ((event: MouseEvent) => void)

    Called when the button is activated. Loading and disabled buttons do not call it.

  • status
    ButtonStatus

    Controlled visual state. Loading remains focusable and refuses activation.

  • disabled
    boolean

    Prevents activation. A disabled link loses its destination and is skipped by Tab.

  • variant
    ButtonVariant

    Visual style. Choose it for what the action means, not for decoration.

  • size
    "sm" | "md" | "lg" | "icon"

    Control height. icon is a square button for a single icon.

  • element Bindable
    HTMLButtonElement | HTMLAnchorElement

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

  • unstyled
    boolean

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

  • loading
    boolean

    Convenience alias for status="loading".

  • labels
    ButtonLabels

    Overrides the built-in text and accessible names. Every key is optional and English is the fallback.

Other parts

These parts have no props of their own. They pass HTML attributes and events to their element.

  • Form.Actions