FormSquid

shadcn/ui

Shadcn Job Application Form

The first step asks for name, email, LinkedIn, and GitHub. The second asks for experience level, availability, and why the candidate is interested.

Live preview

Job application

Apply for the software engineering role.

Step 1 of 2

Profile

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

What’s included

  • React Hook Form
  • Zod validation
  • shadcn/ui
  • Responsive styling
  • Two steps
  • Radio, date, and link fields

Fields

  • Name · text · required · Profile
  • Email · email · required · Profile
  • LinkedIn · text · Profile
  • GitHub · text · Profile
  • Experience level · radio · required · Experience
  • Available from · date · required · Experience
  • Why are you interested? · textarea · required · Experience

How to customize

  1. Add a role select or a work-authorization checkbox.
  2. Ask FormSquid to require GitHub only for engineering roles.
  3. Publish when you want applications in an inbox rather than your own endpoint.

FAQ