FormSquid

shadcn/ui

Shadcn Waitlist Form

One step is enough for a waitlist. The component validates email and the use-case note, then hands the payload to onSubmit.

Live preview

Join the waitlist

Be first to hear when we open access.

Signup

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

What’s included

  • React Hook Form
  • Zod validation
  • shadcn/ui
  • Responsive styling

Fields

  • Name · text · required
  • Email · email · required
  • Company · text
  • What will you use it for? · textarea · required

How to customize

  1. Add a plan or company-size select if you need to segment the list.
  2. Ask FormSquid to split company details onto a second step.
  3. Publish to store signups without standing up your own endpoint.

FAQ