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
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.


<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}
  
"status" class="text-green-400 mb-4">{$message}
{/if}
"POST" use:enhance> "name"> {#snippet children({ attrs })} Full Name {/snippet} As it appears on your ID "email"> {#snippet children({ attrs })} Email Address "email" {...attrs} bind:value={$form.email} /> {/snippet} "message"> {#snippet children({ attrs })} Message