FormSquid

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

Feedback

Tell us how it went.

Survey

Customize with AI

Open this exact spec in FormSquid. Signed-in accounts go straight to the editor.

Prefer the template page?

Install in your Next.js app

bash
pnpm dlx shadcn@latest add https://formsquid.com/r/feedback-form.json

Code

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": false
63 },
64 {
65 "id": "improve",
66 "type": "textarea",
67 "label": "What could be better?",
68 "required": false
69 }
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 <PopoverTrigger
153 render={
154 <Button
155 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 <Calendar
167 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 <FormField
243 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 Back
261 </Button>
262 ) : null}
263 <div className={submitSlotClassName}>
264 {stepIndex < spec.steps.length - 1 ? (
265 <Button type="button" className={submitClassName} onClick={handleNext}>
266 Next
267 </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 <Button
331 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 Remove
340 </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 <Input
392 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

  1. Add a product or visit-date field if you need to group responses.
  2. Ask FormSquid to require the improvement note when satisfaction is low.
  3. Publish to read feedback in the submissions inbox.

FAQ