shadcn/ui
Shadcn Feedback Form
A radio group records satisfaction. Two text areas collect what went well and what could be better. Only the rating is required.
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/feedback-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": "Feedback",30 "description": "Tell us how it went.",31 "submitLabel": "Send feedback",32 "successMessage": "Thanks for the feedback.",33 "steps": [34 {35 "id": "survey",36 "title": "Survey",37 "fields": [38 {39 "id": "satisfaction",40 "type": "radio",41 "label": "How satisfied are you?",42 "required": true,43 "options": [44 {45 "value": "1",46 "label": "Not satisfied"47 },48 {49 "value": "2",50 "label": "Okay"51 },52 {53 "value": "3",54 "label": "Very satisfied"55 }56 ]57 },58 {59 "id": "went_well",60 "type": "textarea",61 "label": "What went well?",62 "required": false63 },64 {65 "id": "improve",66 "type": "textarea",67 "label": "What could be better?",68 "required": false69 }70 ]71 }72 ],73 "appearance": {74 "theme": "dark",75 "accent": "orange",76 "radius": "md",77 "width": "sm",78 "submitWidth": "full"79 }80} as {81 title: string;82 description?: string;83 submitLabel: string;84 successMessage: string;85 steps: Array<{86 id: string;87 title: string;88 fields: Array<{89 id: string;90 type: string;91 label: string;92 description?: string;93 placeholder?: string;94 required: boolean;95 options?: Array<{ value: string; label: string }>;96 visibleWhen?: { fieldId: string; equals: string };97 maxFiles?: 1 | 5;98 maxFileSizeMb?: number;99 accept?: string[];100 }>;101 }>;102};103const appearanceClassName = "mx-auto w-full max-w-sm dark";104const 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.769 0.188 70.08)","--primary-foreground":"oklch(0.145 0 0)","--ring":"oklch(0.769 0.188 70.08)","--radius":"0.625rem","backgroundColor":"var(--background)","color":"var(--foreground)"};105const submitClassName = "w-full";106const submitSlotClassName = "min-w-0 flex-1";107const appearanceTheme = "dark";108 109function parseNumberInput(raw: string): number | undefined {110 if (raw === "") {111 return undefined;112 }113 const value = Number(raw);114 return Number.isFinite(value) ? value : undefined;115}116 117function parseIsoDate(value: string): Date | undefined {118 if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {119 return undefined;120 }121 const [year, month, day] = value.split("-").map(Number);122 if (!year || !month || !day) {123 return undefined;124 }125 const date = new Date(year, month - 1, day);126 if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {127 return undefined;128 }129 return date;130}131 132function toIsoDate(date: Date): string {133 const year = date.getFullYear();134 const month = String(date.getMonth() + 1).padStart(2, "0");135 const day = String(date.getDate()).padStart(2, "0");136 return `${year}-${month}-${day}`;137}138 139function DateField({140 value,141 onChange,142 placeholder,143}: {144 value?: unknown;145 onChange: (value: unknown) => void;146 placeholder?: string;147}) {148 const [open, setOpen] = useState(false);149 const date = typeof value === "string" ? parseIsoDate(value) : undefined;150 return (151 <Popover open={open} onOpenChange={setOpen}>152 <PopoverTrigger153 render={154 <Button155 type="button"156 variant="outline"157 data-empty={!date}158 className="w-full justify-between font-normal data-[empty=true]:text-muted-foreground"159 />160 }161 >162 {date ? format(date, "PPP") : <span>{placeholder ?? "Pick a date"}</span>}163 <ChevronDownIcon data-icon="inline-end" />164 </PopoverTrigger>165 <PopoverContent className="w-auto p-0" align="start">166 <Calendar167 mode="single"168 selected={date}169 defaultMonth={date}170 onSelect={(next) => {171 onChange(next ? toIsoDate(next) : undefined);172 setOpen(false);173 }}174 />175 </PopoverContent>176 </Popover>177 );178}179 180export function ExportedForm({181 onSubmit,182}: {183 onSubmit: (data: Record<string, unknown>) => void | Promise<void>;184}) {185 const [stepIndex, setStepIndex] = useState(0);186 const [done, setDone] = useState(false);187 const [submitError, setSubmitError] = useState("");188 const form = useForm({189 resolver: zodResolver(submissionSchema),190 defaultValues: {},191 });192 const watched = useWatch({ control: form.control });193 const step = spec.steps[stepIndex];194 if (!step) {195 return null;196 }197 198 function handleNext() {199 const parsed = stepSchemas[step.id as keyof typeof stepSchemas].safeParse(form.getValues());200 if (!parsed.success) {201 for (const issue of parsed.error.issues) {202 const name = String(issue.path[0] ?? step.id);203 form.setError(name, { message: issue.message });204 }205 return;206 }207 setStepIndex((current: number) => current + 1);208 }209 210 async function handleSubmit(values: unknown) {211 setSubmitError("");212 try {213 await onSubmit(((values as Record<string, unknown>) ?? {}));214 setDone(true);215 } catch (error) {216 const message = error instanceof Error && error.message ? error.message : "Could not submit. Try again.";217 setSubmitError(message);218 }219 }220 221 if (done) {222 return (223 <div className={appearanceClassName + " space-y-4 rounded-xl px-6 py-8 text-center"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>224 <p>{spec.successMessage}</p>225 </div>226 );227 }228 229 return (230 <Form {...form}>231 <form onSubmit={form.handleSubmit(handleSubmit)} className={appearanceClassName + " space-y-6 rounded-xl px-6 py-6"} style={appearanceStyle} data-formsquid-theme={appearanceTheme}>232 <div>233 <h1 className="text-2xl font-semibold">{spec.title}</h1>234 {spec.description ? <p className="text-muted-foreground">{spec.description}</p> : null}235 </div>236 <h2 className="text-lg font-medium">{step.title}</h2>237 {step.fields.map((field) => {238 if (field.visibleWhen && !conditionMet(spec, watched ?? {}, field)) {239 return null;240 }241 return (242 <FormField243 key={field.id}244 control={form.control}245 name={field.id}246 render={({ field: control }) => (247 <FormItem>248 <FormLabel>{field.label}</FormLabel>249 <FormControl>{renderControl(field, control)}</FormControl>250 {field.description ? <FormDescription>{field.description}</FormDescription> : null}251 <FormMessage />252 </FormItem>253 )}254 />255 );256 })}257 <div className="flex gap-2">258 {stepIndex > 0 ? (259 <Button type="button" variant="outline" className="shrink-0" onClick={() => setStepIndex((current: number) => current - 1)}>260 Back261 </Button>262 ) : null}263 <div className={submitSlotClassName}>264 {stepIndex < spec.steps.length - 1 ? (265 <Button type="button" className={submitClassName} onClick={handleNext}>266 Next267 </Button>268 ) : (269 <Button type="submit" className={submitClassName}>{spec.submitLabel}</Button>270 )}271 </div>272 </div>273 {submitError ? <p role="alert">{submitError}</p> : null}274 </form>275 </Form>276 );277}278 279function conditionMet(formSpec: typeof spec, values: Record<string, unknown>, field: (typeof spec.steps)[number]["fields"][number]) {280 const rule = field.visibleWhen;281 if (!rule) {282 return true;283 }284 const parent = formSpec.steps.flatMap((item) => item.fields).find((item) => item.id === rule.fieldId);285 if (!parent) {286 return false;287 }288 const value = values[parent.id];289 if (parent.type === "checkbox") {290 return (value === true && rule.equals === "true") || (value === false && rule.equals === "false");291 }292 if (parent.type === "number") {293 return typeof value === "number" && JSON.stringify(value) === rule.equals;294 }295 return value === rule.equals;296}297 298 299function formatBytes(bytes: number) {300 if (bytes < 1024) return bytes + " B";301 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes < 10240 ? 1 : 0) + " KB";302 return (bytes / (1024 * 1024)).toFixed(bytes < 10485760 ? 1 : 0) + " MB";303}304 305function FileField({306 field,307 value,308 onChange,309}: {310 field: (typeof spec.steps)[number]["fields"][number];311 value?: unknown;312 onChange: (value: unknown) => void;313}) {314 const maxFiles = field.maxFiles === 5 ? 5 : 1;315 const accept = Array.isArray(field.accept) && field.accept.length > 0 ? field.accept.join(",") : undefined;316 const files = Array.isArray(value) ? value : value ? [value] : [];317 318 function handleChange(list: FileList | null) {319 if (!list || list.length === 0) return;320 const selected = Array.from(list).slice(0, maxFiles);321 onChange(maxFiles === 1 ? selected[0] : selected);322 }323 324 return (325 <div className="space-y-2">326 <Input type="file" accept={accept} multiple={maxFiles > 1} onChange={(event) => handleChange(event.target.files)} />327 {files.map((file: { name: string; size: number }, index: number) => (328 <div key={index} className="flex items-center justify-between gap-2 text-sm">329 <span className="truncate">{file.name} · {formatBytes(file.size)}</span>330 <Button331 type="button"332 variant="ghost"333 size="sm"334 onClick={() => {335 const next = files.filter((_: unknown, itemIndex: number) => itemIndex !== index);336 onChange(next.length === 0 ? undefined : maxFiles === 1 ? next[0] : next);337 }}338 >339 Remove340 </Button>341 </div>342 ))}343 <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>344 </div>345 );346}347 348 349function renderControl(field: (typeof spec.steps)[number]["fields"][number], control: { value?: unknown; onChange: (value: unknown) => void }) {350 if (field.type === "file") {351 return <FileField field={field} value={control.value} onChange={control.onChange} />;352 }353 if (field.type === "textarea") {354 return <Textarea placeholder={field.placeholder} value={String(control.value ?? "")} onChange={(event) => control.onChange(event.target.value)} />;355 }356 if (field.type === "select") {357 return (358 <Select items={field.options?.map((option) => ({ label: option.label, value: option.value }))} value={String(control.value ?? "")} onValueChange={control.onChange}>359 <SelectTrigger>360 <SelectValue placeholder={field.placeholder ?? "Select"} />361 </SelectTrigger>362 <SelectContent>363 {field.options?.map((option) => (364 <SelectItem key={option.value} value={option.value}>365 {option.label}366 </SelectItem>367 ))}368 </SelectContent>369 </Select>370 );371 }372 if (field.type === "radio") {373 return (374 <RadioGroup value={String(control.value ?? "")} onValueChange={control.onChange}>375 {field.options?.map((option) => (376 <label key={option.value} className="flex items-center gap-2">377 <RadioGroupItem value={option.value} />378 {option.label}379 </label>380 ))}381 </RadioGroup>382 );383 }384 if (field.type === "checkbox") {385 return <Checkbox checked={Boolean(control.value)} onCheckedChange={(checked) => control.onChange(checked === true)} />;386 }387 if (field.type === "date") {388 return <DateField value={control.value} onChange={control.onChange} placeholder={field.placeholder} />;389 }390 return (391 <Input392 type={field.type === "number" ? "number" : field.type === "email" ? "email" : "text"}393 placeholder={field.placeholder}394 value={field.type === "number" ? (typeof control.value === "number" && Number.isFinite(control.value) ? control.value : "") : String(control.value ?? "")}395 onChange={(event) =>396 control.onChange(field.type === "number" ? parseNumberInput(event.target.value) : event.target.value)397 }398 />399 );400}What’s included
- React Hook Form
- Zod validation
- shadcn/ui
- Responsive styling
- Radio group
Fields
- How satisfied are you? · radio · required
- What went well? · textarea
- What could be better? · textarea
How to customize
- Add a product or visit-date field if you need to group responses.
- Ask FormSquid to require the improvement note when satisfaction is low.
- Publish to read feedback in the submissions inbox.
FAQ
Related forms
- Feedback template — Preview and publish the survey
- Conditional form — Follow-up fields from an answer
- Contact form — A message instead of a rating
- Waitlist form — Collect interest before launch
