Building shadcn-svelte Forms with Zod, Superforms & Full Accessibility






shadcn-svelte Forms: Validation, Accessibility & Zod Guide







Complete Guide · 2025

Building shadcn-svelte Forms with Zod, Superforms & Full Accessibility

A practical, code-first walkthrough for SvelteKit developers who want type-safe validation,
real accessibility semantics, and a component system that doesn’t get in the way.



~14 min read



Updated July 2025



Svelte 5 · SvelteKit · TypeScript

📌 Semantic Keyword Clusters

🔵 Primary
  • shadcn-svelte forms
  • shadcn-svelte form components
  • shadcn-svelte tutorial
  • shadcn-svelte installation
  • shadcn-svelte examples
🟣 Validation
  • Svelte form validation
  • Zod validation Svelte
  • type-safe form validation Svelte
  • SvelteKit Superforms
  • SvelteKit forms with validation
🟢 Accessibility
  • accessible Svelte forms
  • form accessibility Svelte
  • Svelte form best practices
  • Svelte 5 form handling
🟡 LSI / Supporting
  • Zod schema SvelteKit
  • SvelteKit form actions
  • progressive enhancement forms
  • aria-invalid Svelte
  • superForm helper
  • Svelte 5 runes forms

Forms are where most UIs live and most codebases silently suffer.
shadcn-svelte, Superforms, and Zod form a stack that eliminates the usual mess —
no more hand-rolling error state, no more mismatched labels, no more guessing whether
validation lives on the client or the server. This guide shows you exactly how to wire them together.

Why shadcn-svelte Changes the Forms Game

shadcn-svelte
is not a component library you install as a black-box npm dependency and pray nothing
breaks on the next major release. It’s a collection of copy-paste primitives — built on
Bits UI and
Melt UI — that land
directly in your src/lib/components/ui folder. You own every line. That
distinction matters enormously for form accessibility: instead of fighting a
library’s opinion about ARIA roles, you read the code, understand it, and adapt it.

The form components in shadcn-svelte — FormField, FormLabel,
FormControl, FormMessage, FormDescription — are
designed around a context pattern. A parent Form.Field creates a Svelte context
that all child primitives consume to wire for/id pairs, inject
aria-describedby, and toggle aria-invalid automatically.
This is not boilerplate you write; it’s structure you get for free by composing components
the way they were designed.

Combine that with
SvelteKit Superforms
and you get progressive enhancement out of the box: your form works without JavaScript,
validates on the server via
Zod schemas, and hydrates
into a rich client-side experience when JS loads. In 2025, that’s table stakes, not a bonus.

🧩

Copy-paste ownership

Components live in your repo. No surprise breaking changes from upstream.

🔐

Type-safe by default

Zod schema drives both runtime validation and TypeScript types simultaneously.

Accessible primitives

ARIA wiring handled in context. No manual aria-* juggling.

Progressive enhancement

Works without JS. Enhances with JS. Superforms handles both paths.

Installation & Project Setup

Start with a fresh SvelteKit project. Svelte 5 and TypeScript are assumed — if you’re still
on Svelte 4 you can follow along but the runes section won’t apply.
SvelteKit scaffolding
gives you the skeleton; then you layer in the tools.

# 1 — scaffold a new SvelteKit project
npx sv create my-app
cd my-app
npm install

# 2 — init shadcn-svelte (Svelte 5 / Tailwind v4 path)
npx shadcn-svelte@latest init

# 3 — add the form component collection
npx shadcn-svelte@latest add form input label button textarea

# 4 — install Superforms and Zod
npm install sveltekit-superforms zod
bash

The shadcn-svelte init command writes a components.json config,
installs Tailwind (if not present), and sets up the CSS variable palette.
After running add form, inspect src/lib/components/ui/form:
you’ll find index.ts re-exporting Form.Root, Form.Field,
Form.Control, Form.Label, Form.Message, and
Form.Description. These primitives are your building blocks for every
accessible Svelte form you’ll write going forward.

One thing worth calling out: the CLI installs only what you ask for. There’s no massive
dependency tree. The form primitive depends on Formsnap (the underlying
headless adapter between Superforms and shadcn-svelte) which is pulled automatically.
Check package.json — you’ll see formsnap and
sveltekit-superforms listed alongside your usual SvelteKit deps.
That’s the complete stack: Zod → Superforms → Formsnap → shadcn-svelte form components.

shadcn-svelte installation note:
Tailwind v4 (CSS-first config) is the default for new projects. If you’re adding shadcn-svelte
to an existing Tailwind v3 project, pass --no-tailwind and manually import the
CSS variables. The shadcn-svelte docs cover both paths.

Defining Your Zod Schema

The Zod schema is the single source of truth for your form. It defines field shapes,
validation rules, and error messages. It also generates the TypeScript type you’ll use
everywhere else — no redundant interface declarations, no drift between runtime
validation and compile-time types. This is what type-safe form validation in Svelte
actually means in practice.

// src/lib/schemas/contact.ts
import { z } from 'zod';

export const contactSchema = z.object({
  name: z
    .string()
    .min(2, 'Name must be at least 2 characters')
    .max(60, 'Name is too long'),

  email: z
    .string()
    .email('Please enter a valid email address'),

  message: z
    .string()
    .min(10, 'Message must be at least 10 characters')
    .max(500),

  subscribe: z.boolean().default(false),
});

export type ContactSchema = typeof contactSchema;
TypeScript

Notice that ContactSchema is exported as a type alias for the schema itself,
not for the inferred output type. This is intentional: Superforms needs the schema type
for its generics, not the plain object type. z.infer<typeof contactSchema>
is what you use when you need the plain data shape — for instance, when typing a POST
handler or a database call. Keeping both exports available prevents the subtle bug where
you accidentally validate against a stale type.

For more complex scenarios — nested objects, discriminated unions, file uploads —
Zod’s advanced API
composes cleanly. You can use z.superRefine() for cross-field validation
(e.g., confirming a password match) and pass a custom error path so Superforms routes
the error to the correct field rather than a root-level message. This is one area where
Zod outshines alternatives like Yup: the error shape is predictable, and Superforms knows
exactly how to map it onto your form fields.

Wiring Up SvelteKit Superforms

Sveltekit Superforms
follows a simple contract: superValidate() runs on the server in your
+page.server.ts, returns a validated form object, and that object flows to the
client via the standard SvelteKit load/action cycle. On the client, superForm()
turns it into reactive stores (or Svelte 5 rune-friendly state) that your template consumes.
No global state library needed, no custom stores to maintain.

// src/routes/contact/+page.server.ts
import { superValidate, message } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { contactSchema } from '$lib/schemas/contact';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  return {
    form: await superValidate(zod(contactSchema)),
  };
};

export const actions: Actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(contactSchema));

    if (!form.valid) {
      return { form }; // errors flow back automatically
    }

    // ✅ form.data is fully typed — do your DB / email / API call here
    await sendContactEmail(form.data);

    return message(form, 'Message sent successfully!');
  },
};
TypeScript

The zod() adapter call is a Superforms v2 requirement — it explicitly tells the
library which validation library you’re using, which opens the door to using Valibot, Yup,
or Arktype in the same project if you need to. In practice you’ll almost always use Zod,
but the adapter pattern keeps Superforms itself agnostic. When validation fails,
Superforms serializes the errors and field values and returns them through the SvelteKit
action response — no extra work on your part.

Server-side validation with superValidate(request, zod(schema)) also handles
the awkward edge cases: FormData coercion (strings to numbers, checkbox to boolean),
empty string to undefined, and file fields. You stop writing request.formData()
boilerplate and start trusting the typed form.data object that comes out.
If you’ve ever spent an afternoon debugging why a checkbox value was the string
"on" instead of true, you’ll appreciate this immediately.

Pro tip:
Return the form object from your load function with
{ form: await superValidate(zod(schema)) } — no initial data needed.
Superforms populates defaults from your Zod schema (.default() calls),
so your form renders with sensible initial state without any manual initialization.

Using shadcn-svelte Form Components

With the server side done, the client page wires everything together. The
superForm() call on the client returns form (the stores/state),
enhance (the progressive enhancement action), and errors /
constraints (reactive error and HTML constraint objects).
shadcn-svelte’s Form.* components accept a form prop (the
Superforms form object) and a name prop (the field key) — that’s enough for
them to self-configure.

<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  import { superForm } from 'sveltekit-superforms';
  import * as Form from '$lib/components/ui/form';
  import { Input } from '$lib/components/ui/input';
  import { Textarea } from '$lib/components/ui/textarea';
  import { Button } from '$lib/components/ui/button';
  import { Checkbox } from '$lib/components/ui/checkbox';

  let { data } = $props(); // Svelte 5 runes syntax

  const { form, errors, enhance, message, submitting } = superForm(data.form, {
    validators: zod(contactSchema), // optional: client-side validation
    dataType: 'json',             // serialize as JSON, not FormData
  });
</script>

{#if $message}
  <div role="status" class="text-green-400 mb-4">{$message}</div>
{/if}

<form method="POST" use:enhance>

  <Form.Field {form} name="name">
    <Form.Control>
      {#snippet children({ attrs })}
        <Form.Label>Full Name</Form.Label>
        <Input {...attrs} bind:value={$form.name} />
      {/snippet}
    </Form.Control>
    <Form.Description>As it appears on your ID</Form.Description>
    <Form.FieldErrors />
  </Form.Field>

  <Form.Field {form} name="email">
    <Form.Control>
      {#snippet children({ attrs })}
        <Form.Label>Email Address</Form.Label>
        <Input type="email" {...attrs} bind:value={$form.email} />
      {/snippet}
    </Form.Control>
    <Form.FieldErrors />
  </Form.Field>

  <Form.Field {form} name="message">
    <Form.Control>
      {#snippet children({ attrs })}
        <Form.Label>Message</Form.Label>
        <Textarea {...attrs} bind:value={$form.message} rows={5} />
      {/snippet}
    </Form.Control>
    <Form.FieldErrors />
  </Form.Field>

  <Button type="submit" disabled={$submitting}>
    {$submitting ? 'Sending…' : 'Send Message'}
  </Button>

</form>
Svelte

The attrs snippet prop is the key to the whole system. Form.Control
generates a unique id for the field and passes it down through attrs
along with aria-invalid, aria-describedby, and
aria-required. Spreading {...attrs} onto your Input
is what makes the label association and error announcements work correctly — no manual
id management, no forgetting to set aria-invalid.

Form.FieldErrors renders the error messages from $errors.fieldName
inside a <p> with the correct id that
aria-describedby points to. If there are no errors, the element is absent from
the DOM entirely — not just hidden — so it doesn’t create phantom live-region announcements.
This is the kind of subtle correctness you get from a system designed with accessibility
in mind rather than bolted on afterward. For a deeper look at how the original article
approaches this,
this dev.to walkthrough on building accessible forms with shadcn-svelte
is worth reading alongside this guide.

Svelte 5 Runes & Form State

Svelte 5 ships a new reactivity model — runes — which replaces the reactive
declaration syntax ($:) and Svelte stores with explicit primitives:
$state, $derived, $effect. The good news for
Svelte 5 form handling
with Superforms: the library detects whether you’re in a runes context and adjusts accordingly.
superForm() returns reactive state rather than stores when called inside a
Svelte 5 component with runes enabled.

// Svelte 5 runes mode — no $ sigils needed on state
const { form, errors, enhance } = superForm(data.form);

// form.name is a reactive $state field — access it directly
const nameLength = $derived(() => form.name.length);

// Or keep using $form if you prefer the store API — both work
TypeScript

In practice, many teams are still migrating Svelte 4 projects to Svelte 5 and will encounter
a hybrid period. Superforms 2.x supports both modes simultaneously — you can use the
store-based $form syntax (with the dollar sigil) in Svelte 4 components and
the rune-based access in Svelte 5 components within the same project. The compiled output
differs but the authoring API is designed to ease migration rather than force a hard cutover.

One practical implication for Svelte 5 form handling: $effect
replaces $: someVar = derivedValue for side effects. If you previously used a
reactive declaration to trigger something when a field value changed (e.g., fetching
autocomplete suggestions as the user types), you now write
$effect(() => { if (form.city) fetchSuggestions(form.city); }). It’s more
explicit, easier to reason about, and doesn’t silently re-run when unrelated state changes —
a common source of subtle bugs in reactive form code.

Migration heads-up:
If you’re upgrading an existing project from Svelte 4 to Svelte 5, runes mode is opt-in
per file via <svelte:options runes />. You don’t have to migrate
everything at once. Superforms and shadcn-svelte both support the incremental path.

Accessibility Deep-Dive: What the Stack Gets Right

Accessible Svelte forms require more than slapping an
aria-label on an input and calling it a day. The WCAG 2.2 criteria that
trip up most form implementations are 1.3.1 (Info and Relationships), 3.3.1 (Error
Identification), and 3.3.3 (Error Suggestion). In plain English: labels must be
programmatically associated, errors must identify the field they belong to, and error
messages must explain how to fix the problem. shadcn-svelte’s form primitives address
all three — but you still need to write meaningful error messages in your Zod schema.

Let’s trace a concrete example. When Form.Control renders, it creates a
context with a unique id (generated deterministically from the field name and
form id). Form.Label reads that context and sets its for attribute.
Form.FieldErrors renders with an id derived from the field id,
and Form.Control adds that id to aria-describedby.
When Zod reports an error and $errors.name is populated, aria-invalid
flips to true on the input. Screen readers announce: field name, label,
error message, current value — in that order, correctly. This is not something you can
easily reproduce by hand without subtle bugs creeping in.

Beyond the ARIA plumbing, pay attention to focus management. On a failed
submission, Superforms can be configured to shift focus to the first error field —
critical for keyboard-only and switch-access users who may have no visual cue that the
form submission failed. Set scrollToError: 'auto' and
autoFocusOnError: true in your superForm() options to get this
behaviour. Combined with the ARIA state changes, this covers the full loop: error occurs,
user is notified visually, programmatically, and via focus movement.

WCAG Criterion What handles it What you must do
1.3.1 Info & Relationships Form.Label + Form.Control context Nothing — handled automatically
3.3.1 Error Identification Form.FieldErrors + aria-invalid Write clear error text in Zod schema
3.3.3 Error Suggestion Form.Description for hints Add Form.Description with format examples
2.4.7 Focus Visible Tailwind focus ring utilities Don’t remove focus-visible:ring from input styles
2.1.1 Keyboard Accessible Native form elements + Bits UI Avoid custom keyboard traps in modals

Svelte Form Best Practices Worth Internalizing

The most underused feature of this stack is client-side validation mirroring.
Pass your Zod schema to superForm()‘s validators option and
Superforms runs the same validation logic in the browser on change or blur events — without
a round-trip. You get instant feedback during typing but the server is still the authority
at submission time. This two-layer approach means you never rely solely on client-side
validation (which can be bypassed) and you never wait for a server round-trip to show a
simple “email is invalid” message.

Another pattern worth adopting: extract your form schema into a shared $lib/schemas
directory and import it on both the server and the client. This is trivially possible
because Zod runs in both environments. Your +page.server.ts imports the schema
for superValidate(), your +page.svelte imports it for the
validators option, and any API route that accepts the same data shape imports
it too. One schema, consistent validation everywhere — that’s the promise of
type-safe form validation in Svelte, and it’s fully achievable without
a separate API validation library.

Finally, think carefully about what happens after a successful submission. Superforms
handles the response automatically (no full page reload unless you want one), but the
UX after success is up to you. A common pattern: show an inline success message (the
$message store), reset the form with reset() from the
superForm() return value, and optionally redirect with SvelteKit’s
throw redirect() from the server action. If you’re building a multi-step form,
Superforms’ step API and snapshots let you persist partial form state
across navigations — a feature that otherwise requires a significant amount of custom
session or URL-state management.

  • Always validate on the server — client-side validation is UX, not security.
  • Use dataType: 'json' for complex nested schemas or boolean fields.
  • Set invalidateAll: false if your form is embedded in a page with expensive load functions.
  • Use Form.Description for format hints — it improves both UX and WCAG compliance.
  • Export both the schema and its inferred type from your schema file to avoid type drift.

A Note on Multi-Step Forms

Multi-step form handling in SvelteKit deserves a dedicated article, but the short version
is this: Superforms supports a step pattern where each step is a separate
Zod schema, validated independently, with state persisted in the session (server-side)
or URL search params (client-side, limited to serializable data). shadcn-svelte’s
Card, Stepper, and Dialog components compose
naturally with this pattern. The accessibility considerations multiply — focus management
between steps, progress indication for screen readers, and preventing data loss on back
navigation are all areas to think through before you start coding.

For most applications, a single-page form with conditional field visibility (controlled
by reactive state derived from earlier field values) is simpler and more accessible than
a true multi-step wizard. Use Svelte 5’s $derived or a simple
$state boolean to toggle sections, keep all fields in a single Zod schema
with .optional() or .superRefine() for conditional validation,
and let Superforms handle the rest. Reserve the full multi-step pattern for flows where
the later steps depend on data fetched based on earlier answers.

❓ Frequently Asked Questions

How do I integrate Zod validation with shadcn-svelte forms?

Define a Zod schema in a shared $lib/schemas file. On the server, pass it
to Superforms’ superValidate(request, zod(schema)) inside your
+page.server.ts action. Return the result as { form }.
On the client, call superForm(data.form, { validators: zod(schema) }).
shadcn-svelte’s Form.Field components consume the errors from
$errors through Svelte context automatically — no manual error mapping needed.

Does shadcn-svelte support Svelte 5 and runes?

Yes. shadcn-svelte v1+ ships components written with Svelte 5 runes.
Superforms 2.x is also fully runes-compatible, returning reactive state instead of stores
when called inside a runes-mode component. You can use $state and
$derived natively alongside superForm(). For projects in
migration, both runes and store-based access work simultaneously — you’re not forced
to migrate all at once.

How do I make shadcn-svelte forms accessible?

Use the Form.Field, Form.Label, Form.Control,
and Form.FieldErrors primitives. They wire aria-describedby,
aria-invalid, and for/id pairs automatically via
Svelte context. Write descriptive error messages in your Zod schema. Add
Form.Description for format hints. Configure autoFocusOnError: true
and scrollToError: 'auto' in superForm() to shift focus to
the first error field on failed submission. Don’t override Tailwind’s
focus-visible:ring styles on inputs — they’re the keyboard focus indicator.

🔍 SEO Metadata

Title (65 chars)
shadcn-svelte Forms: Validation, Accessibility & Zod Guide

Description (157 chars)
Build type-safe, accessible SvelteKit forms with shadcn-svelte, Zod schema validation, and Superforms. Step-by-step guide with real code examples.

Primary Keywords
shadcn-svelte forms
Zod validation Svelte
SvelteKit Superforms
accessible Svelte forms
Svelte 5 form handling
type-safe form validation Svelte


Contact Us

Want to increase the number of meetings per month in your company? Our team is waiting for you...