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
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/form-builder.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
- Change labels, placeholders, and required fields in the editor.
- Ask FormSquid to add a step, a select, or a condition without rewriting the component.
- Publish when you want a public URL and a submissions inbox. The hosted export posts to your form endpoint.
FAQ
Related forms
- Contact form — Name, email, and message
- Multi-step form — Next and back across steps
- Conditional form — Fields that show when a choice matches
- Templates — Preview a form and publish it
