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
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/waitlist-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": "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": true43 },44 {45 "id": "email",46 "type": "email",47 "label": "Email",48 "required": true49 },50 {51 "id": "company",52 "type": "text",53 "label": "Company",54 "required": false55 },56 {57 "id": "use_case",58 "type": "textarea",59 "label": "What will you use it for?",60 "required": true61 }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 <PopoverTrigger145 render={146 <Button147 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 <Calendar159 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 <FormField235 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 Back253 </Button>254 ) : null}255 <div className={submitSlotClassName}>256 {stepIndex < spec.steps.length - 1 ? (257 <Button type="button" className={submitClassName} onClick={handleNext}>258 Next259 </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 <Button323 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 Remove332 </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 <Input384 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
- Add a plan or company-size select if you need to segment the list.
- Ask FormSquid to split company details onto a second step.
- Publish to store signups without standing up your own endpoint.
FAQ
Related forms
- Waitlist template — The same form, ready to publish
- Contact form — Name, email, and a message
- Feedback form — A short product survey
- Form builder — Generate a different form from a prompt
