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
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/client-intake-form.jsonCode
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": true44 },45 {46 "id": "email",47 "type": "email",48 "label": "Email",49 "placeholder": "ada@studio.com",50 "required": true51 },52 {53 "id": "company",54 "type": "text",55 "label": "Company",56 "placeholder": "Analytical Engines",57 "required": false58 }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": false115 },116 {117 "id": "goals",118 "type": "textarea",119 "label": "Project goals",120 "placeholder": "What should this work accomplish?",121 "required": true122 }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 <PopoverTrigger206 render={207 <Button208 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 <Calendar220 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 <FormField296 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 Back314 </Button>315 ) : null}316 <div className={submitSlotClassName}>317 {stepIndex < spec.steps.length - 1 ? (318 <Button type="button" className={submitClassName} onClick={handleNext}>319 Next320 </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 <Button384 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 Remove393 </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 <Input445 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
- Add a service-type select or a file-free notes field on the project step.
- Ask FormSquid to show budget follow-ups only for a chosen range.
- Publish to collect intakes in an inbox instead of handling onSubmit yourself.
FAQ
Related forms
- Client intake template — The publishable version of this form
- Conditional form — Show fields after a choice
- Multi-step form — How steps are compiled
- Contact form — A shorter single-step form
