FormSquid

shadcn/ui

Shadcn Form Builder

Describe the form, inspect a working preview, and copy source that submits through an onSubmit callback. When you want an inbox, the same spec becomes a hosted FormSquid form.

Live preview

Contact

Send a note and we will get back to you.

Message

Customize with AI

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

Prefer the template page?

Install in your Next.js app

bash
pnpm dlx shadcn@latest add https://formsquid.com/r/form-builder.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": "Contact",
30 "description": "Send a note and we will get back to you.",
31 "submitLabel": "Send message",
32 "successMessage": "Message sent. We will reply by email.",
33 "steps": [
34 {
35 "id": "details",
36 "title": "Message",
37 "fields": [
38 {
39 "id": "name",
40 "type": "text",
41 "label": "Name",
42 "required": true
43 },
44 {
45 "id": "email",
46 "type": "email",
47 "label": "Email",
48 "required": true
49 },
50 {
51 "id": "message",
52 "type": "textarea",
53 "label": "Message",
54 "placeholder": "How can we help?",
55 "required": true
56 }
57 ]
58 }
59 ],
60 "appearance": {
61 "theme": "dark",
62 "accent": "blue",
63 "radius": "md",
64 "width": "sm",
65 "submitWidth": "full"
66 }
67} as {
68 title: string;
69 description?: string;
70 submitLabel: string;
71 successMessage: string;
72 steps: Array<{
73 id: string;
74 title: string;
75 fields: Array<{
76 id: string;
77 type: string;
78 label: string;
79 description?: string;
80 placeholder?: string;
81 required: boolean;
82 options?: Array<{ value: string; label: string }>;
83 visibleWhen?: { fieldId: string; equals: string };
84 maxFiles?: 1 | 5;
85 maxFileSizeMb?: number;
86 accept?: string[];
87 }>;
88 }>;
89};
90const appearanceClassName = "mx-auto w-full max-w-sm dark";
91const 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)"};
92const submitClassName = "w-full";
93const submitSlotClassName = "min-w-0 flex-1";
94const appearanceTheme = "dark";
95 
96function parseNumberInput(raw: string): number | undefined {
97 if (raw === "") {
98 return undefined;
99 }
100 const value = Number(raw);
101 return Number.isFinite(value) ? value : undefined;
102}
103 
104function parseIsoDate(value: string): Date | undefined {
105 if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
106 return undefined;
107 }
108 const [year, month, day] = value.split("-").map(Number);
109 if (!year || !month || !day) {
110 return undefined;
111 }
112 const date = new Date(year, month - 1, day);
113 if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
114 return undefined;
115 }
116 return date;
117}
118 
119function toIsoDate(date: Date): string {
120 const year = date.getFullYear();
121 const month = String(date.getMonth() + 1).padStart(2, "0");
122 const day = String(date.getDate()).padStart(2, "0");
123 return `${year}-${month}-${day}`;
124}
125 
126function DateField({
127 value,
128 onChange,
129 placeholder,
130}: {
131 value?: unknown;
132 onChange: (value: unknown) => void;
133 placeholder?: string;
134}) {
135 const [open, setOpen] = useState(false);
136 const date = typeof value === "string" ? parseIsoDate(value) : undefined;
137 return (
138 <Popover open={open} onOpenChange={setOpen}>
139 <PopoverTrigger
140 render={
141 <Button
142 type="button"
143 variant="outline"
144 data-empty={!date}
145 className="w-full justify-between font-normal data-[empty=true]:text-muted-foreground"
146 />
147 }
148 >
149 {date ? format(date, "PPP") : <span>{placeholder ?? "Pick a date"}</span>}
150 <ChevronDownIcon data-icon="inline-end" />
151 </PopoverTrigger>
152 <PopoverContent className="w-auto p-0" align="start">
153 <Calendar
154 mode="single"
155 selected={date}
156 defaultMonth={date}
157 onSelect={(next) => {
158 onChange(next ? toIsoDate(next) : undefined);
159 setOpen(false);
160 }}
161 />
162 </PopoverContent>
163 </Popover>
164 );
165}
166 
167export function ExportedForm({
168 onSubmit,
169}: {
170 onSubmit: (data: Record<string, unknown>) => void | Promise<void>;
171}) {
172 const [stepIndex, setStepIndex] = useState(0);
173 const [done, setDone] = useState(false);
174 const [submitError, setSubmitError] = useState("");
175 const form = useForm({
176 resolver: zodResolver(submissionSchema),
177 defaultValues: {},
178 });
179 const watched = useWatch({ control: form.control });
180 const step = spec.steps[stepIndex];
181 if (!step) {
182 return null;
183 }
184 
185 function handleNext() {
186 const parsed = stepSchemas[step.id as keyof typeof stepSchemas].safeParse(form.getValues());
187 if (!parsed.success) {
188 for (const issue of parsed.error.issues) {
189 const name = String(issue.path[0] ?? step.id);
190 form.setError(name, { message: issue.message });
191 }
192 return;
193 }
194 setStepIndex((current: number) => current + 1);
195 }
196 
197 async function handleSubmit(values: unknown) {
198 setSubmitError("");
199 try {
200 await onSubmit(((values as Record<string, unknown>) ?? {}));
201 setDone(true);
202 } catch (error) {
203 const message = error instanceof Error && error.message ? error.message : "Could not submit. Try again.";
204 setSubmitError(message);
205 }
206 }
207 
208 if (done) {
209 return (
210 <div className={appearanceClassName + " space-y-4 rounded-xl px-6 py-8 text-center"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>
211 <p>{spec.successMessage}</p>
212 </div>
213 );
214 }
215 
216 return (
217 <Form {...form}>
218 <form onSubmit={form.handleSubmit(handleSubmit)} className={appearanceClassName + " space-y-6 rounded-xl px-6 py-6"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>
219 <div>
220 <h1 className="text-2xl font-semibold">{spec.title}</h1>
221 {spec.description ? <p className="text-muted-foreground">{spec.description}</p> : null}
222 </div>
223 <h2 className="text-lg font-medium">{step.title}</h2>
224 {step.fields.map((field) => {
225 if (field.visibleWhen && !conditionMet(spec, watched ?? {}, field)) {
226 return null;
227 }
228 return (
229 <FormField
230 key={field.id}
231 control={form.control}
232 name={field.id}
233 render={({ field: control }) => (
234 <FormItem>
235 <FormLabel>{field.label}</FormLabel>
236 <FormControl>{renderControl(field, control)}</FormControl>
237 {field.description ? <FormDescription>{field.description}</FormDescription> : null}
238 <FormMessage />
239 </FormItem>
240 )}
241 />
242 );
243 })}
244 <div className="flex gap-2">
245 {stepIndex > 0 ? (
246 <Button type="button" variant="outline" className="shrink-0" onClick={() => setStepIndex((current: number) => current - 1)}>
247 Back
248 </Button>
249 ) : null}
250 <div className={submitSlotClassName}>
251 {stepIndex < spec.steps.length - 1 ? (
252 <Button type="button" className={submitClassName} onClick={handleNext}>
253 Next
254 </Button>
255 ) : (
256 <Button type="submit" className={submitClassName}>{spec.submitLabel}</Button>
257 )}
258 </div>
259 </div>
260 {submitError ? <p role="alert">{submitError}</p> : null}
261 </form>
262 </Form>
263 );
264}
265 
266function conditionMet(formSpec: typeof spec, values: Record<string, unknown>, field: (typeof spec.steps)[number]["fields"][number]) {
267 const rule = field.visibleWhen;
268 if (!rule) {
269 return true;
270 }
271 const parent = formSpec.steps.flatMap((item) => item.fields).find((item) => item.id === rule.fieldId);
272 if (!parent) {
273 return false;
274 }
275 const value = values[parent.id];
276 if (parent.type === "checkbox") {
277 return (value === true && rule.equals === "true") || (value === false && rule.equals === "false");
278 }
279 if (parent.type === "number") {
280 return typeof value === "number" && JSON.stringify(value) === rule.equals;
281 }
282 return value === rule.equals;
283}
284 
285 
286function formatBytes(bytes: number) {
287 if (bytes < 1024) return bytes + " B";
288 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes < 10240 ? 1 : 0) + " KB";
289 return (bytes / (1024 * 1024)).toFixed(bytes < 10485760 ? 1 : 0) + " MB";
290}
291 
292function FileField({
293 field,
294 value,
295 onChange,
296}: {
297 field: (typeof spec.steps)[number]["fields"][number];
298 value?: unknown;
299 onChange: (value: unknown) => void;
300}) {
301 const maxFiles = field.maxFiles === 5 ? 5 : 1;
302 const accept = Array.isArray(field.accept) && field.accept.length > 0 ? field.accept.join(",") : undefined;
303 const files = Array.isArray(value) ? value : value ? [value] : [];
304 
305 function handleChange(list: FileList | null) {
306 if (!list || list.length === 0) return;
307 const selected = Array.from(list).slice(0, maxFiles);
308 onChange(maxFiles === 1 ? selected[0] : selected);
309 }
310 
311 return (
312 <div className="space-y-2">
313 <Input type="file" accept={accept} multiple={maxFiles > 1} onChange={(event) => handleChange(event.target.files)} />
314 {files.map((file: { name: string; size: number }, index: number) => (
315 <div key={index} className="flex items-center justify-between gap-2 text-sm">
316 <span className="truncate">{file.name} · {formatBytes(file.size)}</span>
317 <Button
318 type="button"
319 variant="ghost"
320 size="sm"
321 onClick={() => {
322 const next = files.filter((_: unknown, itemIndex: number) => itemIndex !== index);
323 onChange(next.length === 0 ? undefined : maxFiles === 1 ? next[0] : next);
324 }}
325 >
326 Remove
327 </Button>
328 </div>
329 ))}
330 <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>
331 </div>
332 );
333}
334 
335 
336function renderControl(field: (typeof spec.steps)[number]["fields"][number], control: { value?: unknown; onChange: (value: unknown) => void }) {
337 if (field.type === "file") {
338 return <FileField field={field} value={control.value} onChange={control.onChange} />;
339 }
340 if (field.type === "textarea") {
341 return <Textarea placeholder={field.placeholder} value={String(control.value ?? "")} onChange={(event) => control.onChange(event.target.value)} />;
342 }
343 if (field.type === "select") {
344 return (
345 <Select items={field.options?.map((option) => ({ label: option.label, value: option.value }))} value={String(control.value ?? "")} onValueChange={control.onChange}>
346 <SelectTrigger>
347 <SelectValue placeholder={field.placeholder ?? "Select"} />
348 </SelectTrigger>
349 <SelectContent>
350 {field.options?.map((option) => (
351 <SelectItem key={option.value} value={option.value}>
352 {option.label}
353 </SelectItem>
354 ))}
355 </SelectContent>
356 </Select>
357 );
358 }
359 if (field.type === "radio") {
360 return (
361 <RadioGroup value={String(control.value ?? "")} onValueChange={control.onChange}>
362 {field.options?.map((option) => (
363 <label key={option.value} className="flex items-center gap-2">
364 <RadioGroupItem value={option.value} />
365 {option.label}
366 </label>
367 ))}
368 </RadioGroup>
369 );
370 }
371 if (field.type === "checkbox") {
372 return <Checkbox checked={Boolean(control.value)} onCheckedChange={(checked) => control.onChange(checked === true)} />;
373 }
374 if (field.type === "date") {
375 return <DateField value={control.value} onChange={control.onChange} placeholder={field.placeholder} />;
376 }
377 return (
378 <Input
379 type={field.type === "number" ? "number" : field.type === "email" ? "email" : "text"}
380 placeholder={field.placeholder}
381 value={field.type === "number" ? (typeof control.value === "number" && Number.isFinite(control.value) ? control.value : "") : String(control.value ?? "")}
382 onChange={(event) =>
383 control.onChange(field.type === "number" ? parseNumberInput(event.target.value) : event.target.value)
384 }
385 />
386 );
387}

What’s included

  • React Hook Form
  • Zod validation
  • shadcn/ui
  • Responsive styling

Fields

  • Name · text · required
  • Email · email · required
  • Message · textarea · required

How to customize

  1. Change labels, placeholders, and required fields in the editor.
  2. Ask FormSquid to add a step, a select, or a condition without rewriting the component.
  3. Publish when you want a public URL and a submissions inbox. The hosted export posts to your form endpoint.

FAQ