Form
formuiZod-first form block built on TanStack Svelte Form. Wires field labels, descriptions, error messages, and validation together; bind a field once and the rest is automatic.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/form.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/form.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/form.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/form.jsonManifest:
https://uipkge.dev/r/svelte/form.jsonInstalls to:src/lib/components/ui/form/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
formReact is intentional: React's `Form` is a react-hook-form `FormProvider` (no DOM, methods spread by the consumer), while Svelte renders the `<form>` itself and takes the instance as a prop. | TanstackFormApi | — | required |
ref | HTMLFormElement | null | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
FormFieldContextinterface FormFieldContext {
name: string
error: () => string | undefined
valid: () => boolean
isDirty: () => boolean
isTouched: () => boolean
}TanstackFormApiinterface TanstackFormApi {
Field: Component<any>
handleSubmit: () => void | Promise<void>
}npm dependencies
Includes
Files installed (15)
src/lib/components/ui/form/Form.svelte1.3 kB
<script lang="ts" module> import type { HTMLFormAttributes } from 'svelte/elements' import type { TanstackFormApi } from './context' export interface FormProps extends HTMLFormAttributes { /** TanStack form instance from `createForm`. Structural divergence from * React is intentional: React's `Form` is a react-hook-form * `FormProvider` (no DOM, methods spread by the consumer), while Svelte * renders the `<form>` itself and takes the instance as a prop. */ form: TanstackFormApi ref?: HTMLFormElement | null } </script> <script lang="ts"> import { setContext, untrack } from 'svelte' import { cn } from '$lib/utils' import { FORM_INSTANCE_CONTEXT_KEY } from './context' let { class: className, form, children, ref = $bindable(null), ...restProps }: FormProps = $props() // `untrack` — the form object identity is stable for the component's // lifetime (created once by `createForm`), so initial capture is correct. setContext(FORM_INSTANCE_CONTEXT_KEY, untrack(() => form)) function onSubmit(e: SubmitEvent) { e.preventDefault() e.stopPropagation() void form.handleSubmit() } </script> <form bind:this={ref} data-uipkge data-slot="form" class={cn(className)} onsubmit={onSubmit} {...restProps}> {@render children?.()} </form>src/lib/components/ui/form/FormActions.svelte0.9 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface FormActionsProps extends HTMLAttributes<HTMLDivElement> { align?: 'left' | 'center' | 'right' gap?: 'sm' | 'md' | 'lg' ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, align = 'right', gap = 'md', children, ref = $bindable(null), ...restProps }: FormActionsProps = $props() const alignClasses = { left: 'justify-start', center: 'justify-center', right: 'justify-end', } const gapClasses = { sm: 'gap-2', md: 'gap-3', lg: 'gap-4', } </script> <div bind:this={ref} data-uipkge data-slot="form-actions" class={cn('flex flex-wrap items-center', alignClasses[align], gapClasses[gap], className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/form/FormControl.svelte1.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface FormControlBinding { id: string 'aria-describedby': string 'aria-invalid': boolean /** `form-control` data-slot, applied to the child control via spread — * the compat shim for React's `<Slot data-slot="form-control">`, which * has no Svelte equivalent (this component renders no DOM of its own). */ 'data-slot': 'form-control' } export interface FormControlProps { /** Render-prop children (the Svelte counterpart of Radix `Slot`): spread * the binding onto your control — * `{#snippet children(props)}<Input {...props} />{/snippet}`. */ children?: Snippet<[FormControlBinding]> } </script> <script lang="ts"> import { useFormField } from './useFormField' let { children }: FormControlProps = $props() const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const binding = $derived<FormControlBinding>({ id: formItemId(), 'aria-describedby': !error() ? `${formDescriptionId()}` : `${formDescriptionId()} ${formMessageId()}`, 'aria-invalid': !!error(), 'data-slot': 'form-control', }) </script> {@render children?.(binding)}src/lib/components/ui/form/FormDescription.svelte0.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface FormDescriptionProps extends HTMLAttributes<HTMLParagraphElement> { ref?: HTMLParagraphElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { useFormField } from './useFormField' let { class: className, children, ref = $bindable(null), ...restProps }: FormDescriptionProps = $props() const { formDescriptionId } = useFormField() </script> <p bind:this={ref} id={formDescriptionId()} data-uipkge data-slot="form-description" class={cn('text-muted-foreground text-sm', className)} {...restProps} > {@render children?.()} </p>src/lib/components/ui/form/FormField.svelte1.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { TanstackFormApi } from './context' import type { FormFieldBinding } from './FormFieldInner.svelte' export interface FormFieldProps { form?: TanstackFormApi name: string validators?: Record<string, unknown> children?: Snippet<[FormFieldBinding]> } </script> <script lang="ts"> import { getContext } from 'svelte' import FormFieldInner from './FormFieldInner.svelte' import { FORM_INSTANCE_CONTEXT_KEY } from './context' let { form, name, validators, children }: FormFieldProps = $props() const injected = getContext<TanstackFormApi | undefined>(FORM_INSTANCE_CONTEXT_KEY) const resolvedForm = $derived.by(() => { const f = form ?? injected if (!f) throw new Error('<FormField> requires a `form` prop or to be nested inside <Form form={...}>') return f }) const Field = $derived(resolvedForm.Field) </script> <Field {name} {validators}> {#snippet children(field: any)} <FormFieldInner {field}> {#snippet children(binding)} {@render children?.(binding)} {/snippet} </FormFieldInner> {/snippet} </Field>src/lib/components/ui/form/FormFieldInner.svelte3.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface FieldMeta { errors: Array<unknown> errorMap?: Record<string, Array<unknown> | undefined> isDirty: boolean isTouched: boolean isValid: boolean } export interface FieldApi { name: string state: { value: unknown meta: FieldMeta } handleChange: (v: unknown) => void handleBlur: () => void } /** Spread onto a text-like control (`<Input {...componentField} />`). For * selects, checkboxes, or custom controls, wire `field.handleChange` / * `field.handleBlur` manually — also exposed on the binding. */ export interface ComponentField { name: string // `any` (not `unknown`) so `<Input {...componentField} />` spreads without // a cast at every call site — TanStack field values are form-typed at the // `createForm` call, which this structural binding cannot see. value: any oninput: (e: Event) => void onblur: () => void } export interface FormFieldBinding { field: FieldApi componentField: ComponentField error: string | undefined valid: boolean isDirty: boolean isTouched: boolean } export interface FormFieldInnerProps { field: FieldApi children?: Snippet<[FormFieldBinding]> } </script> <script lang="ts"> import { setContext, untrack } from 'svelte' import { FORM_FIELD_CONTEXT_KEY } from './context' let { field, children }: FormFieldInnerProps = $props() function formatError(raw: unknown): string { if (typeof raw === 'string') return raw if (raw && typeof raw === 'object' && 'message' in raw) { return String((raw as { message: unknown }).message) } return String(raw) } // @tanstack/svelte-form state is runes-reactive, so plain `$derived` // reads track value/meta updates — no manual store subscription needed // (unlike the Vue twin, which snapshots via `store.subscribe`). const error = $derived.by(() => { const meta = field.state.meta const flat = meta.errors if (Array.isArray(flat) && flat.length > 0 && flat[0] != null) { return formatError(flat[0]) } const map = meta.errorMap ?? {} for (const key of Object.keys(map)) { const arr = map[key] if (Array.isArray(arr) && arr.length > 0 && arr[0] != null) { return formatError(arr[0]) } } return undefined }) const valid = $derived(field.state.meta.isValid) const isDirty = $derived(field.state.meta.isDirty) const isTouched = $derived(field.state.meta.isTouched) setContext(FORM_FIELD_CONTEXT_KEY, { // `untrack` — the field name is static; error/valid/dirty/touched stay // reactive through the getter closures below. name: untrack(() => field.name), error: () => error, valid: () => valid, isDirty: () => isDirty, isTouched: () => isTouched, }) const componentField = $derived<ComponentField>({ value: field.state.value, oninput: (e: Event) => field.handleChange((e.target as HTMLInputElement).value), onblur: () => field.handleBlur(), name: field.name, }) const binding = $derived<FormFieldBinding>({ field, componentField, error, valid, isDirty, isTouched }) </script> {@render children?.(binding)}src/lib/components/ui/form/FormItem.svelte3.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { FormStatus } from './types' export interface FormItemProps extends HTMLAttributes<HTMLDivElement> { label?: string required?: boolean description?: string status?: FormStatus help?: string layout?: 'vertical' | 'horizontal' labelWidth?: string /** Extra content beside the label (replaces the Vue `label` slot). */ labelContent?: Snippet ref?: HTMLDivElement | null } // Client-only counter for generated item ids. Assigned in onMount (never // during SSR) so server and client markup stay identical. let nextItemId = 0 </script> <script lang="ts"> import { onMount, setContext } from 'svelte' import { cn } from '$lib/utils' import { FORM_ITEM_CONTEXT_KEY } from './context' import FormLabel from './FormLabel.svelte' let { class: className, label, required = false, description, status, help, layout = 'vertical', labelWidth, labelContent, children, ref = $bindable(null), ...restProps }: FormItemProps = $props() let id = $state<string | undefined>(undefined) // Getter (not the raw string): children initialize before onMount assigns // the id, and `useFormField` resolves ids lazily through this. setContext(FORM_ITEM_CONTEXT_KEY, () => id) onMount(() => { if (id === undefined) { nextItemId += 1 id = `form-item-${nextItemId}` } }) const statusBorderClass = $derived.by(() => { switch (status) { case 'error': return '[&_input]:border-destructive [&_textarea]:border-destructive [&_button]:border-destructive' case 'warning': return '[&_input]:border-warning [&_textarea]:border-warning [&_button]:border-warning' case 'success': return '[&_input]:border-success [&_textarea]:border-success [&_button]:border-success' default: return '' } }) const isHorizontal = $derived(layout === 'horizontal') </script> <div bind:this={ref} data-uipkge data-slot="form-item" class={cn( 'grid gap-1.5', isHorizontal && 'grid-cols-[var(--label-width,140px)_1fr] items-start gap-x-4 gap-y-0', className, )} style:--label-width={labelWidth} {...restProps} > {#if label || labelContent} <div class="flex items-center gap-1"> {#if label} <FormLabel for={`${id}-form-item`} class={status === 'error' ? 'text-destructive' : undefined}> {label} </FormLabel> {/if} {@render labelContent?.()} {#if required} <span class="text-destructive text-sm" aria-hidden="true">*</span> {/if} </div> {/if} <div class={cn('space-y-1', statusBorderClass)}> {@render children?.()} {#if description} <p class="text-muted-foreground text-xs">{description}</p> {/if} {#if help} <p class={cn('text-xs', { 'text-destructive': status === 'error', 'text-warning': status === 'warning', 'text-success': status === 'success', 'text-muted-foreground': !status, })} role={status === 'error' ? 'alert' : undefined} > {help} </p> {/if} </div> </div>src/lib/components/ui/form/FormLabel.svelte0.7 kB
<script lang="ts" module> import type { HTMLLabelAttributes } from 'svelte/elements' export interface FormLabelProps extends HTMLLabelAttributes { ref?: HTMLLabelElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { Label } from '$lib/components/ui/label' import { useFormField } from './useFormField' let { class: className, children, ref = $bindable(null), ...restProps }: FormLabelProps = $props() const { error, formItemId } = useFormField() </script> <Label data-uipkge data-slot="form-label" data-error={!!error()} class={cn('data-[error=true]:text-destructive', className)} bind:ref {...restProps} for={formItemId()} > {@render children?.()} </Label>src/lib/components/ui/form/FormMessage.svelte0.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface FormMessageProps extends HTMLAttributes<HTMLParagraphElement> { ref?: HTMLParagraphElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { useFormField } from './useFormField' let { class: className, children, ref = $bindable(null), ...restProps }: FormMessageProps = $props() const { error, formMessageId } = useFormField() </script> {#if error()} <p bind:this={ref} id={formMessageId()} data-uipkge data-slot="form-message" role="alert" class={cn('text-destructive text-sm', className)} {...restProps} > {error()} </p> {/if}src/lib/components/ui/form/FormSection.svelte1.3 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface FormSectionProps extends HTMLAttributes<HTMLDivElement> { title?: string subtitle?: string description?: string divider?: boolean headingLevel?: 'h2' | 'h3' | 'h4' | 'h5' ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, title, subtitle, description, divider = false, headingLevel = 'h4', children, ref = $bindable(null), ...restProps }: FormSectionProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="form-section" class={cn('space-y-3', className)} {...restProps}> {#if divider || title || subtitle} <div class={divider ? 'border-t pt-4' : undefined}> {#if title || subtitle} <div class="space-y-1"> {#if title} <svelte:element this={headingLevel} class="text-sm font-semibold">{title}</svelte:element> {/if} {#if subtitle} <p class="text-muted-foreground text-xs">{subtitle}</p> {/if} </div> {/if} </div> {/if} {#if description} <p class="text-muted-foreground text-xs">{description}</p> {/if} {@render children?.()} </div>src/lib/components/ui/form/FormStatus.svelte1.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { FormStatus } from './types' export interface FormStatusProps extends HTMLAttributes<HTMLDivElement> { status: FormStatus message: string ref?: HTMLDivElement | null } </script> <script lang="ts"> import type { Component } from 'svelte' import { CircleAlert, CircleCheck, TriangleAlert } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, status, message, ref = $bindable(null), ...restProps }: FormStatusProps = $props() const statusConfig: Record<NonNullable<FormStatus>, { icon: Component<any>; container: string }> = { error: { icon: CircleAlert, container: 'bg-destructive/10 text-destructive border-destructive/20', }, warning: { icon: TriangleAlert, container: 'bg-warning/10 text-warning border-warning/30', }, success: { icon: CircleCheck, container: 'bg-success/10 text-success border-success/30', }, } const Icon = $derived(status ? statusConfig[status].icon : null) const containerClass = $derived(status ? statusConfig[status].container : '') </script> {#if status && Icon} <div bind:this={ref} data-uipkge data-slot="form-status" role={status === 'error' ? 'alert' : 'status'} class={cn('flex items-center gap-2 rounded-md border px-3 py-2 text-sm', containerClass, className)} {...restProps} > <Icon class="size-4 shrink-0" aria-hidden="true" /> <span>{message}</span> </div> {/if}src/lib/components/ui/form/index.ts1.3 kB
export { default as Form, type FormProps } from './Form.svelte' export { default as FormField, type FormFieldProps } from './FormField.svelte' export { default as FormFieldInner, type ComponentField, type FieldApi, type FieldMeta, type FormFieldBinding, type FormFieldInnerProps, } from './FormFieldInner.svelte' export { default as FormControl, type FormControlBinding, type FormControlProps } from './FormControl.svelte' export { default as FormDescription, type FormDescriptionProps } from './FormDescription.svelte' export { default as FormItem, type FormItemProps } from './FormItem.svelte' export { default as FormLabel, type FormLabelProps } from './FormLabel.svelte' export { default as FormMessage, type FormMessageProps } from './FormMessage.svelte' export { default as FormActions, type FormActionsProps } from './FormActions.svelte' export { default as FormSection, type FormSectionProps } from './FormSection.svelte' export { default as FormStatus, type FormStatusProps } from './FormStatus.svelte' export { useFormField } from './useFormField' export { FORM_ITEM_CONTEXT_KEY, FORM_FIELD_CONTEXT_KEY, FORM_INSTANCE_CONTEXT_KEY } from './context' export type { FormFieldContext, TanstackFormApi } from './context' export type { FormStatus as FormStatusValue } from './types' export { createForm } from '@tanstack/svelte-form'src/lib/components/ui/form/context.ts0.5 kB
import type { Component } from 'svelte' export const FORM_ITEM_CONTEXT_KEY = Symbol('form-item') export interface FormFieldContext { name: string error: () => string | undefined valid: () => boolean isDirty: () => boolean isTouched: () => boolean } export const FORM_FIELD_CONTEXT_KEY = Symbol('form-field') export interface TanstackFormApi { Field: Component<any> handleSubmit: () => void | Promise<void> } export const FORM_INSTANCE_CONTEXT_KEY = Symbol('form-instance')src/lib/components/ui/form/types.ts0.1 kB
export type FormStatus = 'error' | 'warning' | 'success' | null | undefinedsrc/lib/components/ui/form/useFormField.ts1.1 kB
import { getContext } from 'svelte' import { FORM_FIELD_CONTEXT_KEY, FORM_ITEM_CONTEXT_KEY, type FormFieldContext } from './context' export function useFormField() { const fieldContext = getContext<FormFieldContext | undefined>(FORM_FIELD_CONTEXT_KEY) const getItemId = getContext<(() => string | undefined) | undefined>(FORM_ITEM_CONTEXT_KEY) if (!fieldContext) throw new Error('useFormField should be used within <FormField>') // Id getters (not plain strings): FormItem assigns its id in onMount so SSR // markup stays identical between server and client. Call these inside // `$derived` or the template and they track the assignment. const formItemId = () => `${getItemId?.()}-form-item` const formDescriptionId = () => `${getItemId?.()}-form-item-description` const formMessageId = () => `${getItemId?.()}-form-item-message` return { id: getItemId, name: () => fieldContext.name, formItemId, formDescriptionId, formMessageId, valid: fieldContext.valid, isDirty: fieldContext.isDirty, isTouched: fieldContext.isTouched, error: fieldContext.error, } }
Raw manifest:https://uipkge.dev/r/svelte/form.json