DEV Community

Lucas Bennett
Lucas Bennett

Posted on

Building Accessible Forms with Validation using shadcn-svelte in Svelte

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, initialize shadcn-svelte in your project:

npx shadcn-svelte@latest init
Enter fullscreen mode Exit fullscreen mode

This command will:

  • Install necessary dependencies (Tailwind CSS, class-variance-authority, etc.)
  • Create a components.json configuration 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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You'll also need to install the form validation dependencies:

npm install sveltekit-superforms zod
Enter fullscreen mode Exit fullscreen mode

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 ... */
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

You'll need to add the Checkbox component:

npx shadcn-svelte@latest add checkbox
Enter fullscreen mode Exit fullscreen mode

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,
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

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>>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Advanced Validation: Learn about custom validators, async validation, and conditional validation rules
  2. Form State Management: Explore form reset, dirty state tracking, and form persistence
  3. Other Components: Check out other shadcn-svelte components like Select, Radio Group, and Date Picker for more complex forms
  4. Accessibility: Deep dive into ARIA attributes and keyboard navigation patterns
  5. 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)