FormSquid

shadcn/ui

Shadcn Multi-Step Form

This example uses three steps: contact, project, and schedule. Next checks the current step. Submit checks the whole form. Empty later steps stay out of the way until the person reaches them.

Live preview

Project onboarding

Three steps: who you are, what you need, and when you want to start.

Step 1 of 3

Contact

Customize with AI

Open this exact spec in FormSquid. Signed-in accounts go straight to the editor.

Install in your Next.js app

bash
pnpm dlx shadcn@latest add https://formsquid.com/r/multi-step-form.json

Code

Standalone source. ExportedForm calls onSubmit with the validated data.

form.tsxtsx
1"use client";
2 
3import { useState } from "react";
4import { useForm, useWatch } from "react-hook-form";
5import { zodResolver } from "@hookform/resolvers/zod";
6import { format } from "date-fns";
7import { ChevronDownIcon } from "lucide-react";
8import { Button } from "@/components/ui/button";
9import { Calendar } from "@/components/ui/calendar";
10import { Checkbox } from "@/components/ui/checkbox";
11import { Input } from "@/components/ui/input";
12import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
13import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
14import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
15import { Textarea } from "@/components/ui/textarea";
16import {
17 Form,
18 FormControl,
19 FormDescription,
20 FormField,
21 FormItem,
22 FormLabel,
23 FormMessage,
24} from "@/components/ui/form";
25import { stepSchemas, submissionSchema } from "./schema";
26 
27const spec = {
28 "schemaVersion": 1,
29 "title": "Project onboarding",
30 "description": "Three steps: who you are, what you need, and when you want to start.",
31 "submitLabel": "Submit onboarding",
32 "successMessage": "Onboarding received.",
33 "steps": [
34 {
35 "id": "contact",
36 "title": "Contact",
37 "fields": [
38 {
39 "id": "name",
40 "type": "text",
41 "label": "Name",
42 "placeholder": "Ada Lovelace",
43 "required": true
44 },
45 {
46 "id": "email",
47 "type": "email",
48 "label": "Email",
49 "placeholder": "ada@studio.com",
50 "required": true
51 }
52 ]
53 },
54 {
55 "id": "project",
56 "title": "Project",
57 "fields": [
58 {
59 "id": "company",
60 "type": "text",
61 "label": "Company",
62 "required": false
63 },
64 {
65 "id": "work_type",
66 "type": "select",
67 "label": "Type of work",
68 "required": true,
69 "options": [
70 {
71 "value": "website",
72 "label": "Website"
73 },
74 {
75 "value": "product",
76 "label": "Product"
77 },
78 {
79 "value": "brand",
80 "label": "Brand"
81 }
82 ]
83 },
84 {
85 "id": "brief",
86 "type": "textarea",
87 "label": "What do you need?",
88 "required": true
89 }
90 ]
91 },
92 {
93 "id": "schedule",
94 "title": "Schedule",
95 "fields": [
96 {
97 "id": "start_date",
98 "type": "date",
99 "label": "Preferred start",
100 "required": true
101 },
102 {
103 "id": "timeline",
104 "type": "radio",
105 "label": "Timeline",
106 "required": true,
107 "options": [
108 {
109 "value": "asap",
110 "label": "As soon as possible"
111 },
112 {
113 "value": "this_quarter",
114 "label": "This quarter"
115 },
116 {
117 "value": "flexible",
118 "label": "Flexible"
119 }
120 ]
121 }
122 ]
123 }
124 ],
125 "appearance": {
126 "theme": "dark",
127 "accent": "blue",
128 "radius": "md",
129 "width": "md",
130 "submitWidth": "auto"
131 }
132} as {
133 title: string;
134 description?: string;
135 submitLabel: string;
136 successMessage: string;
137 steps: Array<{
138 id: string;
139 title: string;
140 fields: Array<{
141 id: string;
142 type: string;
143 label: string;
144 description?: string;
145 placeholder?: string;
146 required: boolean;
147 options?: Array<{ value: string; label: string }>;
148 visibleWhen?: { fieldId: string; equals: string };
149 maxFiles?: 1 | 5;
150 maxFileSizeMb?: number;
151 accept?: string[];
152 }>;
153 }>;
154};
155const appearanceClassName = "mx-auto w-full max-w-xl dark";
156const appearanceStyle = {"--background":"oklch(0.145 0 0)","--foreground":"oklch(0.985 0 0)","--card":"oklch(0.205 0 0)","--card-foreground":"oklch(0.985 0 0)","--popover":"oklch(0.205 0 0)","--popover-foreground":"oklch(0.985 0 0)","--secondary":"oklch(0.269 0 0)","--secondary-foreground":"oklch(0.985 0 0)","--muted":"oklch(0.269 0 0)","--muted-foreground":"oklch(0.708 0 0)","--accent":"oklch(0.269 0 0)","--accent-foreground":"oklch(0.985 0 0)","--border":"oklch(1 0 0 / 10%)","--input":"oklch(1 0 0 / 15%)","--primary":"oklch(0.623 0.214 259.815)","--primary-foreground":"oklch(0.145 0 0)","--ring":"oklch(0.623 0.214 259.815)","--radius":"0.625rem","backgroundColor":"var(--background)","color":"var(--foreground)"};
157const submitClassName = "";
158const submitSlotClassName = "";
159const appearanceTheme = "dark";
160 
161function parseNumberInput(raw: string): number | undefined {
162 if (raw === "") {
163 return undefined;
164 }
165 const value = Number(raw);
166 return Number.isFinite(value) ? value : undefined;
167}
168 
169function parseIsoDate(value: string): Date | undefined {
170 if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
171 return undefined;
172 }
173 const [year, month, day] = value.split("-").map(Number);
174 if (!year || !month || !day) {
175 return undefined;
176 }
177 const date = new Date(year, month - 1, day);
178 if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
179 return undefined;
180 }
181 return date;
182}
183 
184function toIsoDate(date: Date): string {
185 const year = date.getFullYear();
186 const month = String(date.getMonth() + 1).padStart(2, "0");
187 const day = String(date.getDate()).padStart(2, "0");
188 return `${year}-${month}-${day}`;
189}
190 
191function DateField({
192 value,
193 onChange,
194 placeholder,
195}: {
196 value?: unknown;
197 onChange: (value: unknown) => void;
198 placeholder?: string;
199}) {
200 const [open, setOpen] = useState(false);
201 const date = typeof value === "string" ? parseIsoDate(value) : undefined;
202 return (
203 <Popover open={open} onOpenChange={setOpen}>
204 <PopoverTrigger
205 render={
206 <Button
207 type="button"
208 variant="outline"
209 data-empty={!date}
210 className="w-full justify-between font-normal data-[empty=true]:text-muted-foreground"
211 />
212 }
213 >
214 {date ? format(date, "PPP") : <span>{placeholder ?? "Pick a date"}</span>}
215 <ChevronDownIcon data-icon="inline-end" />
216 </PopoverTrigger>
217 <PopoverContent className="w-auto p-0" align="start">
218 <Calendar
219 mode="single"
220 selected={date}
221 defaultMonth={date}
222 onSelect={(next) => {
223 onChange(next ? toIsoDate(next) : undefined);
224 setOpen(false);
225 }}
226 />
227 </PopoverContent>
228 </Popover>
229 );
230}
231 
232export function ExportedForm({
233 onSubmit,
234}: {
235 onSubmit: (data: Record<string, unknown>) => void | Promise<void>;
236}) {
237 const [stepIndex, setStepIndex] = useState(0);
238 const [done, setDone] = useState(false);
239 const [submitError, setSubmitError] = useState("");
240 const form = useForm({
241 resolver: zodResolver(submissionSchema),
242 defaultValues: {},
243 });
244 const watched = useWatch({ control: form.control });
245 const step = spec.steps[stepIndex];
246 if (!step) {
247 return null;
248 }
249 
250 function handleNext() {
251 const parsed = stepSchemas[step.id as keyof typeof stepSchemas].safeParse(form.getValues());
252 if (!parsed.success) {
253 for (const issue of parsed.error.issues) {
254 const name = String(issue.path[0] ?? step.id);
255 form.setError(name, { message: issue.message });
256 }
257 return;
258 }
259 setStepIndex((current: number) => current + 1);
260 }
261 
262 async function handleSubmit(values: unknown) {
263 setSubmitError("");
264 try {
265 await onSubmit(((values as Record<string, unknown>) ?? {}));
266 setDone(true);
267 } catch (error) {
268 const message = error instanceof Error && error.message ? error.message : "Could not submit. Try again.";
269 setSubmitError(message);
270 }
271 }
272 
273 if (done) {
274 return (
275 <div className={appearanceClassName + " space-y-4 rounded-xl px-6 py-8 text-center"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>
276 <p>{spec.successMessage}</p>
277 </div>
278 );
279 }
280 
281 return (
282 <Form {...form}>
283 <form onSubmit={form.handleSubmit(handleSubmit)} className={appearanceClassName + " space-y-6 rounded-xl px-6 py-6"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>
284 <div>
285 <h1 className="text-2xl font-semibold">{spec.title}</h1>
286 {spec.description ? <p className="text-muted-foreground">{spec.description}</p> : null}
287 </div>
288 <h2 className="text-lg font-medium">{step.title}</h2>
289 {step.fields.map((field) => {
290 if (field.visibleWhen && !conditionMet(spec, watched ?? {}, field)) {
291 return null;
292 }
293 return (
294 <FormField
295 key={field.id}
296 control={form.control}
297 name={field.id}
298 render={({ field: control }) => (
299 <FormItem>
300 <FormLabel>{field.label}</FormLabel>
301 <FormControl>{renderControl(field, control)}</FormControl>
302 {field.description ? <FormDescription>{field.description}</FormDescription> : null}
303 <FormMessage />
304 </FormItem>
305 )}
306 />
307 );
308 })}
309 <div className="flex gap-2">
310 {stepIndex > 0 ? (
311 <Button type="button" variant="outline" className="shrink-0" onClick={() => setStepIndex((current: number) => current - 1)}>
312 Back
313 </Button>
314 ) : null}
315 <div className={submitSlotClassName}>
316 {stepIndex < spec.steps.length - 1 ? (
317 <Button type="button" className={submitClassName} onClick={handleNext}>
318 Next
319 </Button>
320 ) : (
321 <Button type="submit" className={submitClassName}>{spec.submitLabel}</Button>
322 )}
323 </div>
324 </div>
325 {submitError ? <p role="alert">{submitError}</p> : null}
326 </form>
327 </Form>
328 );
329}
330 
331function conditionMet(formSpec: typeof spec, values: Record<string, unknown>, field: (typeof spec.steps)[number]["fields"][number]) {
332 const rule = field.visibleWhen;
333 if (!rule) {
334 return true;
335 }
336 const parent = formSpec.steps.flatMap((item) => item.fields).find((item) => item.id === rule.fieldId);
337 if (!parent) {
338 return false;
339 }
340 const value = values[parent.id];
341 if (parent.type === "checkbox") {
342 return (value === true && rule.equals === "true") || (value === false && rule.equals === "false");
343 }
344 if (parent.type === "number") {
345 return typeof value === "number" && JSON.stringify(value) === rule.equals;
346 }
347 return value === rule.equals;
348}
349 
350 
351function formatBytes(bytes: number) {
352 if (bytes < 1024) return bytes + " B";
353 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes < 10240 ? 1 : 0) + " KB";
354 return (bytes / (1024 * 1024)).toFixed(bytes < 10485760 ? 1 : 0) + " MB";
355}
356 
357function FileField({
358 field,
359 value,
360 onChange,
361}: {
362 field: (typeof spec.steps)[number]["fields"][number];
363 value?: unknown;
364 onChange: (value: unknown) => void;
365}) {
366 const maxFiles = field.maxFiles === 5 ? 5 : 1;
367 const accept = Array.isArray(field.accept) && field.accept.length > 0 ? field.accept.join(",") : undefined;
368 const files = Array.isArray(value) ? value : value ? [value] : [];
369 
370 function handleChange(list: FileList | null) {
371 if (!list || list.length === 0) return;
372 const selected = Array.from(list).slice(0, maxFiles);
373 onChange(maxFiles === 1 ? selected[0] : selected);
374 }
375 
376 return (
377 <div className="space-y-2">
378 <Input type="file" accept={accept} multiple={maxFiles > 1} onChange={(event) => handleChange(event.target.files)} />
379 {files.map((file: { name: string; size: number }, index: number) => (
380 <div key={index} className="flex items-center justify-between gap-2 text-sm">
381 <span className="truncate">{file.name} · {formatBytes(file.size)}</span>
382 <Button
383 type="button"
384 variant="ghost"
385 size="sm"
386 onClick={() => {
387 const next = files.filter((_: unknown, itemIndex: number) => itemIndex !== index);
388 onChange(next.length === 0 ? undefined : maxFiles === 1 ? next[0] : next);
389 }}
390 >
391 Remove
392 </Button>
393 </div>
394 ))}
395 <p className="text-xs text-muted-foreground">Files are passed to onSubmit as browser File objects. FormSquid does not upload them in callback mode.</p>
396 </div>
397 );
398}
399 
400 
401function renderControl(field: (typeof spec.steps)[number]["fields"][number], control: { value?: unknown; onChange: (value: unknown) => void }) {
402 if (field.type === "file") {
403 return <FileField field={field} value={control.value} onChange={control.onChange} />;
404 }
405 if (field.type === "textarea") {
406 return <Textarea placeholder={field.placeholder} value={String(control.value ?? "")} onChange={(event) => control.onChange(event.target.value)} />;
407 }
408 if (field.type === "select") {
409 return (
410 <Select items={field.options?.map((option) => ({ label: option.label, value: option.value }))} value={String(control.value ?? "")} onValueChange={control.onChange}>
411 <SelectTrigger>
412 <SelectValue placeholder={field.placeholder ?? "Select"} />
413 </SelectTrigger>
414 <SelectContent>
415 {field.options?.map((option) => (
416 <SelectItem key={option.value} value={option.value}>
417 {option.label}
418 </SelectItem>
419 ))}
420 </SelectContent>
421 </Select>
422 );
423 }
424 if (field.type === "radio") {
425 return (
426 <RadioGroup value={String(control.value ?? "")} onValueChange={control.onChange}>
427 {field.options?.map((option) => (
428 <label key={option.value} className="flex items-center gap-2">
429 <RadioGroupItem value={option.value} />
430 {option.label}
431 </label>
432 ))}
433 </RadioGroup>
434 );
435 }
436 if (field.type === "checkbox") {
437 return <Checkbox checked={Boolean(control.value)} onCheckedChange={(checked) => control.onChange(checked === true)} />;
438 }
439 if (field.type === "date") {
440 return <DateField value={control.value} onChange={control.onChange} placeholder={field.placeholder} />;
441 }
442 return (
443 <Input
444 type={field.type === "number" ? "number" : field.type === "email" ? "email" : "text"}
445 placeholder={field.placeholder}
446 value={field.type === "number" ? (typeof control.value === "number" && Number.isFinite(control.value) ? control.value : "") : String(control.value ?? "")}
447 onChange={(event) =>
448 control.onChange(field.type === "number" ? parseNumberInput(event.target.value) : event.target.value)
449 }
450 />
451 );
452}

What’s included

  • React Hook Form
  • Zod validation
  • shadcn/ui
  • Responsive styling
  • Three steps
  • Per-step Zod schemas

Fields

  • Name · text · required · Contact
  • Email · email · required · Contact
  • Company · text · Project
  • Type of work · select · required · Project
  • What do you need? · textarea · required · Project
  • Preferred start · date · required · Schedule
  • Timeline · radio · required · Schedule

How to customize

  1. Rename steps or move a field to an earlier step.
  2. Ask FormSquid to add a review step that only confirms, or to collapse this back to one step.
  3. Publish if you want the answers stored instead of handled in onSubmit.

FAQ