FormSquid

shadcn/ui

Shadcn Client Intake Form

Two steps collect contact details, then budget, timeline, website, and project goals. Copy the component and schema, or open the same spec in FormSquid and edit it with AI.

Live preview

Client intake

Tell us about the project so we can prepare a useful first conversation.

Step 1 of 2

Contact

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

What’s included

  • React Hook Form
  • Zod validation
  • shadcn/ui
  • Responsive styling
  • Two steps with next and back

Fields

  • Name · text · required · Contact
  • Email · email · required · Contact
  • Company · text · Contact
  • Budget · select · required · Project
  • Timeline · select · required · Project
  • Current website · text · Project
  • Project goals · textarea · required · Project

How to customize

  1. Add a service-type select or a file-free notes field on the project step.
  2. Ask FormSquid to show budget follow-ups only for a chosen range.
  3. Publish to collect intakes in an inbox instead of handling onSubmit yourself.

FAQ