shadcn/ui
Shadcn Conditional Form
A radio chooses bug, feature, or something else. Severity appears only for bugs. The feature note appears only for feature requests. Hidden required fields are not part of the payload until they are visible.
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/conditional-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": "Support request",30 "description": "Choose a request type. Extra fields appear only when they apply.",31 "submitLabel": "Send request",32 "successMessage": "Request received.",33 "steps": [34 {35 "id": "request",36 "title": "Request",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": "topic",52 "type": "radio",53 "label": "What do you need?",54 "required": true,55 "options": [56 {57 "value": "bug",58 "label": "Report a bug"59 },60 {61 "value": "feature",62 "label": "Request a feature"63 },64 {65 "value": "other",66 "label": "Something else"67 }68 ]69 },70 {71 "id": "severity",72 "type": "select",73 "label": "Severity",74 "required": true,75 "options": [76 {77 "value": "low",78 "label": "Low"79 },80 {81 "value": "high",82 "label": "High"83 },84 {85 "value": "blocking",86 "label": "Blocking"87 }88 ],89 "visibleWhen": {90 "fieldId": "topic",91 "equals": "bug"92 }93 },94 {95 "id": "use_case",96 "type": "textarea",97 "label": "What should this feature do?",98 "required": true,99 "visibleWhen": {100 "fieldId": "topic",101 "equals": "feature"102 }103 },104 {105 "id": "details",106 "type": "textarea",107 "label": "Details",108 "required": true109 }110 ]111 }112 ],113 "appearance": {114 "theme": "dark",115 "accent": "violet",116 "radius": "md",117 "width": "md",118 "submitWidth": "full"119 }120} as {121 title: string;122 description?: string;123 submitLabel: string;124 successMessage: string;125 steps: Array<{126 id: string;127 title: string;128 fields: Array<{129 id: string;130 type: string;131 label: string;132 description?: string;133 placeholder?: string;134 required: boolean;135 options?: Array<{ value: string; label: string }>;136 visibleWhen?: { fieldId: string; equals: string };137 maxFiles?: 1 | 5;138 maxFileSizeMb?: number;139 accept?: string[];140 }>;141 }>;142};143const appearanceClassName = "mx-auto w-full max-w-xl dark";144const 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)"};145const submitClassName = "w-full";146const submitSlotClassName = "min-w-0 flex-1";147const appearanceTheme = "dark";148 149function parseNumberInput(raw: string): number | undefined {150 if (raw === "") {151 return undefined;152 }153 const value = Number(raw);154 return Number.isFinite(value) ? value : undefined;155}156 157function parseIsoDate(value: string): Date | undefined {158 if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {159 return undefined;160 }161 const [year, month, day] = value.split("-").map(Number);162 if (!year || !month || !day) {163 return undefined;164 }165 const date = new Date(year, month - 1, day);166 if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {167 return undefined;168 }169 return date;170}171 172function toIsoDate(date: Date): string {173 const year = date.getFullYear();174 const month = String(date.getMonth() + 1).padStart(2, "0");175 const day = String(date.getDate()).padStart(2, "0");176 return `${year}-${month}-${day}`;177}178 179function DateField({180 value,181 onChange,182 placeholder,183}: {184 value?: unknown;185 onChange: (value: unknown) => void;186 placeholder?: string;187}) {188 const [open, setOpen] = useState(false);189 const date = typeof value === "string" ? parseIsoDate(value) : undefined;190 return (191 <Popover open={open} onOpenChange={setOpen}>192 <PopoverTrigger193 render={194 <Button195 type="button"196 variant="outline"197 data-empty={!date}198 className="w-full justify-between font-normal data-[empty=true]:text-muted-foreground"199 />200 }201 >202 {date ? format(date, "PPP") : <span>{placeholder ?? "Pick a date"}</span>}203 <ChevronDownIcon data-icon="inline-end" />204 </PopoverTrigger>205 <PopoverContent className="w-auto p-0" align="start">206 <Calendar207 mode="single"208 selected={date}209 defaultMonth={date}210 onSelect={(next) => {211 onChange(next ? toIsoDate(next) : undefined);212 setOpen(false);213 }}214 />215 </PopoverContent>216 </Popover>217 );218}219 220export function ExportedForm({221 onSubmit,222}: {223 onSubmit: (data: Record<string, unknown>) => void | Promise<void>;224}) {225 const [stepIndex, setStepIndex] = useState(0);226 const [done, setDone] = useState(false);227 const [submitError, setSubmitError] = useState("");228 const form = useForm({229 resolver: zodResolver(submissionSchema),230 defaultValues: {},231 });232 const watched = useWatch({ control: form.control });233 const step = spec.steps[stepIndex];234 if (!step) {235 return null;236 }237 238 function handleNext() {239 const parsed = stepSchemas[step.id as keyof typeof stepSchemas].safeParse(form.getValues());240 if (!parsed.success) {241 for (const issue of parsed.error.issues) {242 const name = String(issue.path[0] ?? step.id);243 form.setError(name, { message: issue.message });244 }245 return;246 }247 setStepIndex((current: number) => current + 1);248 }249 250 async function handleSubmit(values: unknown) {251 setSubmitError("");252 try {253 await onSubmit(((values as Record<string, unknown>) ?? {}));254 setDone(true);255 } catch (error) {256 const message = error instanceof Error && error.message ? error.message : "Could not submit. Try again.";257 setSubmitError(message);258 }259 }260 261 if (done) {262 return (263 <div className={appearanceClassName + " space-y-4 rounded-xl px-6 py-8 text-center"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>264 <p>{spec.successMessage}</p>265 </div>266 );267 }268 269 return (270 <Form {...form}>271 <form onSubmit={form.handleSubmit(handleSubmit)} className={appearanceClassName + " space-y-6 rounded-xl px-6 py-6"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>272 <div>273 <h1 className="text-2xl font-semibold">{spec.title}</h1>274 {spec.description ? <p className="text-muted-foreground">{spec.description}</p> : null}275 </div>276 <h2 className="text-lg font-medium">{step.title}</h2>277 {step.fields.map((field) => {278 if (field.visibleWhen && !conditionMet(spec, watched ?? {}, field)) {279 return null;280 }281 return (282 <FormField283 key={field.id}284 control={form.control}285 name={field.id}286 render={({ field: control }) => (287 <FormItem>288 <FormLabel>{field.label}</FormLabel>289 <FormControl>{renderControl(field, control)}</FormControl>290 {field.description ? <FormDescription>{field.description}</FormDescription> : null}291 <FormMessage />292 </FormItem>293 )}294 />295 );296 })}297 <div className="flex gap-2">298 {stepIndex > 0 ? (299 <Button type="button" variant="outline" className="shrink-0" onClick={() => setStepIndex((current: number) => current - 1)}>300 Back301 </Button>302 ) : null}303 <div className={submitSlotClassName}>304 {stepIndex < spec.steps.length - 1 ? (305 <Button type="button" className={submitClassName} onClick={handleNext}>306 Next307 </Button>308 ) : (309 <Button type="submit" className={submitClassName}>{spec.submitLabel}</Button>310 )}311 </div>312 </div>313 {submitError ? <p role="alert">{submitError}</p> : null}314 </form>315 </Form>316 );317}318 319function conditionMet(formSpec: typeof spec, values: Record<string, unknown>, field: (typeof spec.steps)[number]["fields"][number]) {320 const rule = field.visibleWhen;321 if (!rule) {322 return true;323 }324 const parent = formSpec.steps.flatMap((item) => item.fields).find((item) => item.id === rule.fieldId);325 if (!parent) {326 return false;327 }328 const value = values[parent.id];329 if (parent.type === "checkbox") {330 return (value === true && rule.equals === "true") || (value === false && rule.equals === "false");331 }332 if (parent.type === "number") {333 return typeof value === "number" && JSON.stringify(value) === rule.equals;334 }335 return value === rule.equals;336}337 338 339function formatBytes(bytes: number) {340 if (bytes < 1024) return bytes + " B";341 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes < 10240 ? 1 : 0) + " KB";342 return (bytes / (1024 * 1024)).toFixed(bytes < 10485760 ? 1 : 0) + " MB";343}344 345function FileField({346 field,347 value,348 onChange,349}: {350 field: (typeof spec.steps)[number]["fields"][number];351 value?: unknown;352 onChange: (value: unknown) => void;353}) {354 const maxFiles = field.maxFiles === 5 ? 5 : 1;355 const accept = Array.isArray(field.accept) && field.accept.length > 0 ? field.accept.join(",") : undefined;356 const files = Array.isArray(value) ? value : value ? [value] : [];357 358 function handleChange(list: FileList | null) {359 if (!list || list.length === 0) return;360 const selected = Array.from(list).slice(0, maxFiles);361 onChange(maxFiles === 1 ? selected[0] : selected);362 }363 364 return (365 <div className="space-y-2">366 <Input type="file" accept={accept} multiple={maxFiles > 1} onChange={(event) => handleChange(event.target.files)} />367 {files.map((file: { name: string; size: number }, index: number) => (368 <div key={index} className="flex items-center justify-between gap-2 text-sm">369 <span className="truncate">{file.name} · {formatBytes(file.size)}</span>370 <Button371 type="button"372 variant="ghost"373 size="sm"374 onClick={() => {375 const next = files.filter((_: unknown, itemIndex: number) => itemIndex !== index);376 onChange(next.length === 0 ? undefined : maxFiles === 1 ? next[0] : next);377 }}378 >379 Remove380 </Button>381 </div>382 ))}383 <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>384 </div>385 );386}387 388 389function renderControl(field: (typeof spec.steps)[number]["fields"][number], control: { value?: unknown; onChange: (value: unknown) => void }) {390 if (field.type === "file") {391 return <FileField field={field} value={control.value} onChange={control.onChange} />;392 }393 if (field.type === "textarea") {394 return <Textarea placeholder={field.placeholder} value={String(control.value ?? "")} onChange={(event) => control.onChange(event.target.value)} />;395 }396 if (field.type === "select") {397 return (398 <Select items={field.options?.map((option) => ({ label: option.label, value: option.value }))} value={String(control.value ?? "")} onValueChange={control.onChange}>399 <SelectTrigger>400 <SelectValue placeholder={field.placeholder ?? "Select"} />401 </SelectTrigger>402 <SelectContent>403 {field.options?.map((option) => (404 <SelectItem key={option.value} value={option.value}>405 {option.label}406 </SelectItem>407 ))}408 </SelectContent>409 </Select>410 );411 }412 if (field.type === "radio") {413 return (414 <RadioGroup value={String(control.value ?? "")} onValueChange={control.onChange}>415 {field.options?.map((option) => (416 <label key={option.value} className="flex items-center gap-2">417 <RadioGroupItem value={option.value} />418 {option.label}419 </label>420 ))}421 </RadioGroup>422 );423 }424 if (field.type === "checkbox") {425 return <Checkbox checked={Boolean(control.value)} onCheckedChange={(checked) => control.onChange(checked === true)} />;426 }427 if (field.type === "date") {428 return <DateField value={control.value} onChange={control.onChange} placeholder={field.placeholder} />;429 }430 return (431 <Input432 type={field.type === "number" ? "number" : field.type === "email" ? "email" : "text"}433 placeholder={field.placeholder}434 value={field.type === "number" ? (typeof control.value === "number" && Number.isFinite(control.value) ? control.value : "") : String(control.value ?? "")}435 onChange={(event) =>436 control.onChange(field.type === "number" ? parseNumberInput(event.target.value) : event.target.value)437 }438 />439 );440}What’s included
- React Hook Form
- Zod validation
- shadcn/ui
- Responsive styling
- Conditional fields
Fields
- Name · text · required
- Email · email · required
- What do you need? · radio · required
- Severity · select · required · shown when topic is bug
- What should this feature do? · textarea · required · shown when topic is feature
- Details · textarea · required
How to customize
- Point a condition at an earlier select, radio, checkbox, or number.
- Ask FormSquid to add another branch, or to require details only for blocking bugs.
- Publish when you want the requests stored with the same visibility rules.
FAQ
Related forms
- RSVP template — A meal field when the guest is coming
- Client intake form — Add conditions to a longer form
- Multi-step form — Split questions across steps
- Feedback form — A rating that can grow a follow-up
