# Multi-step forms with shadcn

One useForm for every step, trigger() on the visible fields before moving on, and noValidate on the form: values persist and validation never blocks.

> Page: https://ui.eneawork.it/blog/shadcn-multi-step-form
> Markdown: https://ui.eneawork.it/blog/shadcn-multi-step-form.md

*2026-11-01*

Risposta breve: tieni un solo useForm per tutti gli step, valida i campi visibili con form.trigger() prima di avanzare e metti noValidate sul form. Lo stato del wizard è un indice, non un secondo schema: se duplichi il form per step perdi valori ed errori.

Il catalogo EneUI misura 111.985 componenti, 1.556 blocchi, 12.808 icone da 1.472 librerie, letti da /api/stats il 1 novembre 2026. 17 blocchi su 1.556 hanno «form» nell'id e 3 sono nella categoria form, contati da data/blocks.json lo stesso giorno.

## Lo schema, non i form

Lo schema Zod descrive tutti i campi, anche quelli che l'utente non vede ancora. La validazione per step non richiede schemi separati: trigger accetta una lista di nomi e valida solo quelli. Così il submit finale resta una sola chiamata a handleSubmit.

const schema = z.object({ email: z.string().email("Email non valida"), name: z.string().min(2, "Almeno 2 caratteri"), plan: z.enum(["free", "pro"]), }); const steps: { title: string; fields: ("email" | "name" | "plan")[] }[] = [ { title: "Account", fields: ["email", "name"] }, { title: "Piano", fields: ["plan"] }, ];

## Il wizard completo

Il componente sotto è autosufficiente: un indice step, un useForm con zodResolver, il pulsante Avanti che chiama trigger, il pulsante Indietro che non valida nulla. I campi nascosti restano montati solo quando servono, ma i valori restano in react-hook-form perché shouldUnregister è false per impostazione predefinita.

"use client"; import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; const schema = z.object({ email: z.string().email("Email non valida"), name: z.string().min(2, "Almeno 2 caratteri"), plan: z.enum(["free", "pro"]), }); const steps: { title: string; fields: ("email" | "name" | "plan")[] }[] = [ { title: "Account", fields: ["email", "name"] }, { title: "Piano", fields: ["plan"] }, ]; export function SignupWizard() { const [step, setStep] = useState(0); const form = useForm>({ resolver: zodResolver(schema), defaultValues: { email: "", name: "", plan: "free" }, }); async function next() { const valid = await form.trigger(steps[step].fields); if (valid) setStep((value) => Math.min(value + 1, steps.length - 1)); } return ( console.log(values))}> Passo {step + 1} di {steps.length}: {steps[step].title} {step === 0 && ( <> ( Email )} /> ( Nome )} /> )} {step === 1 && Piano scelto: {form.watch("plan")}} setStep((value) => value - 1)}> Indietro {step Avanti ) : ( Crea account )} ); }

## Perché noValidate

Il browser valida i campi required prima del submit. Se un campo obbligatorio appartiene a uno step che non è montato, Chrome blocca l'invio e scrive in console «An invalid form control with name='' is not focusable»: l'utente vede un pulsante che non fa nulla. noValidate disattiva quel controllo e lascia la validazione a Zod, che sa quali campi contano in quel momento.

## Le trappole

## Accessibilità

Il cambio di step sposta il contenuto ma non il focus: chi usa la tastiera resta sul pulsante Avanti e non sa che la pagina è cambiata. Dichiara il passo con aria-live="polite", come nel codice sopra, e porta il focus sul titolo del nuovo step. Gli errori restano collegati al campo da FormMessage, che usa già aria-describedby.

## Limite dichiarato

Lo stato del wizard vive nel client: un refresh azzera i valori, perché react-hook-form non salva da nessuna parte. Se il form è lungo, salva una bozza in sessionStorage o sul server, ma non aspettarti che la libreria lo faccia. Il catalogo, misurato il 1 novembre 2026, contiene 17 blocchi form: nessuno implementa un wizard a più passaggi, quindi il codice sopra va adattato al tuo schema.

## Dove continuare

Nel catalogo trovi il record del multi-step form, un blocco form con anteprima e la categoria form. Per le basi leggi che cosa è shadcn/ui e come tematizzare shadcn/ui; il confronto tra blocchi è in migliori form blocks 2026.

## FAQ

### Serve uno schema Zod per ogni step?

No. Un solo schema con tutti i campi basta: form.trigger(["email", "name"]) valida solo quelli indicati e lascia intatti gli altri. Schemi separati costringono a ricomporre i valori alla fine.

### Perché i valori spariscono cambiando step?

Perché stai usando un useForm diverso per ogni step, oppure hai impostato shouldUnregister a true. Con un solo useForm e il valore predefinito false i campi smontati mantengono il valore.

### Perché il pulsante di invio non fa nulla?

Quasi sempre per un campo required in uno step nascosto: il browser blocca il submit e non può portare il focus sul campo. Aggiungi noValidate al form e lascia la validazione a Zod.

### Come faccio vedere gli errori solo dopo Avanti?

Usa mode: "onTouched" oppure lascia mode: "onSubmit" e chiama trigger sui campi dello step. Il messaggio compare dopo il primo tentativo, non mentre l'utente digita.

### Come annuncio il cambio di step?

Metti aria-live="polite" su un paragrafo che descrive il passo e sposta il focus sul titolo del nuovo step. Così chi usa la tastiera o lo screen reader sa che il contenuto è cambiato.
