shadcn/ui
Shadcn Contact Form
A single step with name, email, and message. The Zod schema checks the email and requires the message before onSubmit runs.
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/contact-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": "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": true43 },44 {45 "id": "email",46 "type": "email",47 "label": "Email",48 "required": true49 },50 {51 "id": "message",52 "type": "textarea",53 "label": "Message",54 "placeholder": "How can we help?",55 "required": true56 }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 <PopoverTrigger140 render={141 <Button142 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 <Calendar154 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 <FormField230 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 Back248 </Button>249 ) : null}250 <div className={submitSlotClassName}>251 {stepIndex < spec.steps.length - 1 ? (252 <Button type="button" className={submitClassName} onClick={handleNext}>253 Next254 </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 <Button318 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 Remove327 </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 <Input379 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
- Add a subject select or a phone field.
- Ask FormSquid to require a company name for sales inquiries.
- Publish if you want messages in an inbox instead of your own handler.
FAQ
Related forms
- Contact template — Publish this form without writing the handler
- Feedback form — Satisfaction plus two notes
- Client intake form — A longer two-step brief
- Form builder — Generate the fields from a prompt
