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
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/job-application-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": "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": true43 },44 {45 "id": "email",46 "type": "email",47 "label": "Email",48 "required": true49 },50 {51 "id": "linkedin",52 "type": "text",53 "label": "LinkedIn",54 "placeholder": "https://linkedin.com/in/",55 "required": false56 },57 {58 "id": "github",59 "type": "text",60 "label": "GitHub",61 "placeholder": "https://github.com/",62 "required": false63 }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": true95 },96 {97 "id": "interest",98 "type": "textarea",99 "label": "Why are you interested?",100 "required": true101 }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 <PopoverTrigger185 render={186 <Button187 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 <Calendar199 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 <FormField275 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 Back293 </Button>294 ) : null}295 <div className={submitSlotClassName}>296 {stepIndex < spec.steps.length - 1 ? (297 <Button type="button" className={submitClassName} onClick={handleNext}>298 Next299 </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 <Button363 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 Remove372 </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 <Input424 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
- Add a role select or a work-authorization checkbox.
- Ask FormSquid to require GitHub only for engineering roles.
- Publish when you want applications in an inbox rather than your own endpoint.
FAQ
Related forms
- Job application template — Preview and publish the same form
- Multi-step form — Step navigation in the compiler
- Conditional form — Reveal fields from an earlier answer
- Contact form — A single-step message form
