shadcn-svelte is a collection of beautifully-designed, accessible UI components for Svelte and SvelteKit, built with TypeScript, Tailwind CSS, and Bits UI primitives. It provides a powerful form system that integrates seamlessly with SvelteKit Superforms and Zod for type-safe validation. This guide walks through building accessible, validated forms using shadcn-svelte with Svelte, covering everything from installation to a complete working example. This is part 2 of a series on using shadcn-svelte with Svelte.
Prerequisites
Before starting, ensure you have:
- Node.js v20 or higher installed
- SvelteKit project set up (version 2.0 or later)
- Basic understanding of Svelte 5 runes (
$props,$state, snippets) - Familiarity with TypeScript and Tailwind CSS
- Knowledge of Zod for schema validation (helpful but not required)
Here's a quick example of what we'll build - a form that validates user input in real-time:
<!-- Example: Form field with automatic validation -->
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} bind:value={$formData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
This snippet shows how shadcn-svelte handles form validation automatically - the Form.Field component manages validation state, and Form.FieldErrors displays error messages when validation fails.
Installation
First, ensure you have a SvelteKit project. If you don't have one, create it:
npm create svelte@latest my-app
cd my-app
npm install
Next, initialize shadcn-svelte in your project:
npx shadcn-svelte@latest init
This command will:
- Install necessary dependencies (Tailwind CSS, class-variance-authority, etc.)
- Create a
components.jsonconfiguration file - Set up path aliases in your project
- Configure CSS variables for theming
You'll be prompted to answer configuration questions. For most projects, the defaults work well. The CLI will create a components.json file that looks like this:
{
"$schema": "https://www.shadcn-svelte.com/schema.json",
"aliases": {
"lib": "$lib",
"components": "$lib/components",
"ui": "$lib/components/ui",
"utils": "$lib/utils",
"hooks": "$lib/hooks"
},
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"typescript": true,
"registry": "https://www.shadcn-svelte.com/registry"
}
Now, add the required components for forms:
npx shadcn-svelte@latest add form
npx shadcn-svelte@latest add input
npx shadcn-svelte@latest add button
npx shadcn-svelte@latest add label
You'll also need to install the form validation dependencies:
npm install sveltekit-superforms zod
Project Setup
After installation, verify your project structure. Components should be in src/lib/components/ui/. Your src/app.css should include Tailwind directives and CSS variables:
/* src/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* ... other CSS variables ... */
}
}
Ensure your vite.config.js (or vite.config.ts) has the path alias configured:
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit],
resolve: {
alias: {
$lib: './src/lib'
}
}
});
First Example / Basic Usage
Let's create a simple form with a single field to understand the basics. Create a new page at src/routes/contact/+page.svelte:
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import * as Form from "$lib/components/ui/form/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { formSchema, type FormSchema } from "./schema";
import {
type SuperValidated,
type Infer,
superForm,
} from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
let { data }: { data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
validators: zod4Client(formSchema),
});
const { form: formData, enhance } = form;
</script>
<form method="POST" use:enhance>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} bind:value={$formData.email} />
{/snippet}
</Form.Control>
<Form.Description>We'll never share your email with anyone else.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Button>Submit</Form.Button>
</form>
Create the schema file src/routes/contact/schema.ts:
// src/routes/contact/schema.ts
import { z } from "zod";
export const formSchema = z.object({
email: z.string().email("Please enter a valid email address"),
});
export type FormSchema = typeof formSchema;
And the server-side logic in src/routes/contact/+page.server.ts:
// src/routes/contact/+page.server.ts
import type { PageServerLoad, Actions } from "./$types.js";
import { fail } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
export const load: PageServerLoad = async () => {
return {
form: await superValidate(zod4(formSchema)),
};
};
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod4(formSchema));
if (!form.valid) {
return fail(400, {
form,
});
}
// Form is valid, process the data
console.log("Form submitted:", form.data);
return {
form,
};
},
};
This example demonstrates:
- Form.Field: Wraps each form field and manages its validation state
- Form.Control: Provides the control wrapper with accessibility attributes
- Form.Label: Accessible label automatically associated with the input
- Form.Description: Optional helper text for the field
- Form.FieldErrors: Automatically displays validation errors
- Form.Button: Submit button that respects form validation state
The form validates on submit and shows error messages if the email is invalid.
Practical Example / Building Something Real
Now let's build a complete contact form with multiple fields, different input types, and comprehensive validation. This will be a production-ready example you can use in your projects.
First, update the schema to include more fields:
// src/routes/contact/schema.ts
import { z } from "zod";
export const formSchema = z.object({
name: z.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name must be less than 50 characters"),
email: z.string()
.email("Please enter a valid email address"),
phone: z.string()
.regex(/^\+?[\d\s-()]+$/, "Please enter a valid phone number")
.optional()
.or(z.literal("")),
subject: z.string()
.min(5, "Subject must be at least 5 characters")
.max(100, "Subject must be less than 100 characters"),
message: z.string()
.min(10, "Message must be at least 10 characters")
.max(1000, "Message must be less than 1000 characters"),
newsletter: z.boolean().default(false),
});
export type FormSchema = typeof formSchema;
Update the page component with the complete form:
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import * as Form from "$lib/components/ui/form/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
import { formSchema, type FormSchema } from "./schema";
import {
type SuperValidated,
type Infer,
superForm,
} from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
let { data }: { data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
validators: zod4Client(formSchema),
});
const { form: formData, enhance } = form;
</script>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">Contact Us</h1>
<form method="POST" use:enhance class="space-y-6">
<!-- Name Field -->
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Full Name</Form.Label>
<Input
{...props}
bind:value={$formData.name}
placeholder="John Doe"
type="text"
/>
{/snippet}
</Form.Control>
<Form.Description>Your full name as it should appear in our records.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<!-- Email Field -->
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email Address</Form.Label>
<Input
{...props}
bind:value={$formData.email}
placeholder="john@example.com"
type="email"
/>
{/snippet}
</Form.Control>
<Form.Description>We'll use this to respond to your inquiry.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<!-- Phone Field (Optional) -->
<Form.Field {form} name="phone">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Phone Number (Optional)</Form.Label>
<Input
{...props}
bind:value={$formData.phone}
placeholder="+1 (555) 123-4567"
type="tel"
/>
{/snippet}
</Form.Control>
<Form.Description>Optional - for urgent matters only.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<!-- Subject Field -->
<Form.Field {form} name="subject">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Subject</Form.Label>
<Input
{...props}
bind:value={$formData.subject}
placeholder="What is this regarding?"
type="text"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<!-- Message Field -->
<Form.Field {form} name="message">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Message</Form.Label>
<textarea
{...props}
bind:value={$formData.message}
placeholder="Tell us more about your inquiry..."
class="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
rows="5"
/>
{/snippet}
</Form.Control>
<Form.Description>Please provide as much detail as possible.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<!-- Newsletter Checkbox -->
<Form.Field {form} name="newsletter">
<Form.Control>
{#snippet children({ props })}
<div class="flex items-center space-x-2">
<Checkbox
id="newsletter"
bind:checked={$formData.newsletter}
{...props}
/>
<Form.Label for="newsletter" class="!mt-0 cursor-pointer">
Subscribe to our newsletter for updates and tips
</Form.Label>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<!-- Submit Button -->
<Form.Button class="w-full" size="lg">
Send Message
</Form.Button>
</form>
</div>
You'll need to add the Checkbox component:
npx shadcn-svelte@latest add checkbox
Update the server action to handle the form submission:
// src/routes/contact/+page.server.ts
import type { PageServerLoad, Actions } from "./$types.js";
import { fail } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
export const load: PageServerLoad = async () => {
return {
form: await superValidate(zod4(formSchema)),
};
};
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event, zod4(formSchema));
if (!form.valid) {
return fail(400, {
form,
});
}
// Here you would typically:
// - Save to database
// - Send email notification
// - Process the form data
console.log("Contact form submitted:", form.data);
// Return success state
return {
form,
success: true,
};
},
};
This complete example demonstrates:
- Multiple field types: text, email, tel, textarea, checkbox
- Comprehensive validation: different rules for each field
- Optional fields: phone number with proper handling
- Accessibility: proper labels, descriptions, and error messages
- User experience: clear feedback and helpful descriptions
- Type safety: full TypeScript support throughout
Common Issues / Troubleshooting
Issue 1: "Cannot find module '$lib/components/ui/form'"
Solution: Ensure you've run npx shadcn-svelte@latest add form and that your path aliases are correctly configured in vite.config.ts and tsconfig.json.
Issue 2: Form validation not working
Solution: Make sure you've installed both sveltekit-superforms and zod, and that your schema is properly exported. Also verify that you're using zod4Client for client-side validation and zod4 for server-side validation.
Issue 3: Error messages not displaying
Solution: Ensure Form.FieldErrors is included inside Form.Field but outside Form.Control. The component structure must be: Form.Field > Form.Control > Form.Label + Input, then Form.FieldErrors as a sibling to Form.Control.
Issue 4: TypeScript errors with form types
Solution: Make sure you're using the correct types from sveltekit-superforms. The pattern is:
type SuperValidated<Infer<FormSchema>>
Also ensure your schema type is exported correctly: export type FormSchema = typeof formSchema;
Next Steps
Now that you understand how to build forms with shadcn-svelte, here's what to explore next:
- Advanced Validation: Learn about custom validators, async validation, and conditional validation rules
- Form State Management: Explore form reset, dirty state tracking, and form persistence
- Other Components: Check out other shadcn-svelte components like Select, Radio Group, and Date Picker for more complex forms
- Accessibility: Deep dive into ARIA attributes and keyboard navigation patterns
- Styling: Customize form components with Tailwind CSS and CSS variables
For more information, visit the shadcn-svelte documentation and the SvelteKit Superforms documentation.
Summary
You've learned how to build accessible, validated forms using shadcn-svelte with SvelteKit. The combination of shadcn-svelte's form components, SvelteKit Superforms, and Zod provides a powerful, type-safe solution for handling form validation. You should now be able to create production-ready forms with proper validation, error handling, and accessibility features.

Top comments (0)