Job application
One open question instead of a cover letter, with an optional CV and no demographic fields.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { FileUpload } from '@/components/ui/file-upload'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, required, url } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Job application
*
* One open question instead of a cover-letter upload, and a portfolio field
* that accepts any URL. The demographic and salary questions most application
* forms bury are absent by design — this is the shape of a form that respects
* the applicant's time.
*/
export default function JobApplicationForm() {
const form = useDemoForm({
schema: {
name: { validators: [required('Name')] },
email: { validators: [required('Email'), email()] },
role: { initial: 'frontend', validators: [required('Role')] },
location: { validators: [required('Location')] },
portfolio: { validators: [url()] },
availability: { initial: 'month' },
motivation: { validators: [required('Answer'), minLength(60, 'Answer')] },
},
})
return (
<FormShell
title="Apply to join"
description="Four fields and one real question. No cover letter required."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Submit application"
submittingLabel="Submitting"
onReset={form.reset}
successTitle="Application received"
width="lg"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="name" label="Name" required error={form.error('name')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
</Field>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('email')} />
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="role" label="Role" required error={form.error('role')}>
{(field) => (
<Select
{...field}
{...form.field('role')}
options={[
{ value: 'frontend', label: 'Frontend engineer' },
{ value: 'design-systems', label: 'Design systems engineer' },
{ value: 'accessibility', label: 'Accessibility specialist' },
{ value: 'docs', label: 'Documentation engineer' },
]}
/>
)}
</Field>
<Field
name="location"
label="Where are you based?"
required
error={form.error('location')}
hint="City and timezone."
>
{(field) => <Input {...field} placeholder="Lisbon, WET" {...form.field('location')} />}
</Field>
</div>
<Field
name="portfolio"
label="Portfolio or repository"
showOptional
error={form.error('portfolio')}
>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="example.com/work"
{...form.field('portfolio')}
/>
)}
</Field>
<Fieldset legend="Availability" name="availability">
<Radio
id="avail-now"
name="availability"
value="now"
label="Immediately"
checked={form.values.availability === 'now'}
onChange={() => form.setValue('availability', 'now')}
/>
<Radio
id="avail-month"
name="availability"
value="month"
label="Within a month"
checked={form.values.availability === 'month'}
onChange={() => form.setValue('availability', 'month')}
/>
<Radio
id="avail-later"
name="availability"
value="later"
label="Three months or more"
checked={form.values.availability === 'later'}
onChange={() => form.setValue('availability', 'later')}
/>
</Fieldset>
<Field
name="motivation"
label="Tell us about a component you are proud of"
required
error={form.error('motivation')}
hint="What it does, what was hard, and what you would change now."
>
{(field) => <Textarea {...field} rows={6} {...form.field('motivation')} />}
</Field>
<Field name="cv" label="CV" showOptional hint="Optional — the answer above matters more.">
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
accept="application/pdf"
hint="PDF, up to 5 MB"
maxSizeBytes={5 * 1024 * 1024}
/>
)}
</Field>
</FormShell>
)
}
components/blocks/forms/job-application.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { FileUpload } from '@/components/ui/file-upload'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, required, url } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Job application
*
* One open question instead of a cover-letter upload, and a portfolio field
* that accepts any URL. The demographic and salary questions most application
* forms bury are absent by design — this is the shape of a form that respects
* the applicant's time.
*/
export default function JobApplicationForm() {
const form = useDemoForm({
schema: {
name: { validators: [required('Name')] },
email: { validators: [required('Email'), email()] },
role: { initial: 'frontend', validators: [required('Role')] },
location: { validators: [required('Location')] },
portfolio: { validators: [url()] },
availability: { initial: 'month' },
motivation: { validators: [required('Answer'), minLength(60, 'Answer')] },
},
})
return (
<FormShell
title="Apply to join"
description="Four fields and one real question. No cover letter required."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Submit application"
submittingLabel="Submitting"
onReset={form.reset}
successTitle="Application received"
width="lg"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="name" label="Name" required error={form.error('name')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('name')} />}
</Field>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('email')} />
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="role" label="Role" required error={form.error('role')}>
{(field) => (
<Select
{...field}
{...form.field('role')}
options={[
{ value: 'frontend', label: 'Frontend engineer' },
{ value: 'design-systems', label: 'Design systems engineer' },
{ value: 'accessibility', label: 'Accessibility specialist' },
{ value: 'docs', label: 'Documentation engineer' },
]}
/>
)}
</Field>
<Field
name="location"
label="Where are you based?"
required
error={form.error('location')}
hint="City and timezone."
>
{(field) => <Input {...field} placeholder="Lisbon, WET" {...form.field('location')} />}
</Field>
</div>
<Field
name="portfolio"
label="Portfolio or repository"
showOptional
error={form.error('portfolio')}
>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="example.com/work"
{...form.field('portfolio')}
/>
)}
</Field>
<Fieldset legend="Availability" name="availability">
<Radio
id="avail-now"
name="availability"
value="now"
label="Immediately"
checked={form.values.availability === 'now'}
onChange={() => form.setValue('availability', 'now')}
/>
<Radio
id="avail-month"
name="availability"
value="month"
label="Within a month"
checked={form.values.availability === 'month'}
onChange={() => form.setValue('availability', 'month')}
/>
<Radio
id="avail-later"
name="availability"
value="later"
label="Three months or more"
checked={form.values.availability === 'later'}
onChange={() => form.setValue('availability', 'later')}
/>
</Fieldset>
<Field
name="motivation"
label="Tell us about a component you are proud of"
required
error={form.error('motivation')}
hint="What it does, what was hard, and what you would change now."
>
{(field) => <Textarea {...field} rows={6} {...form.field('motivation')} />}
</Field>
<Field name="cv" label="CV" showOptional hint="Optional — the answer above matters more.">
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
accept="application/pdf"
hint="PDF, up to 5 MB"
maxSizeBytes={5 * 1024 * 1024}
/>
)}
</Field>
</FormShell>
)
}
components/ui/file-upload.tsx
'use client'
import { useRef, useState, type DragEvent } from 'react'
import { UploadCloud, File as FileIcon, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { Button } from './button'
/**
* FileUpload
*
* A drop zone that is genuinely operable from the keyboard: the visible
* surface is a `<button>` that opens the native picker, so Space and Enter
* work, and the file input itself stays in the DOM for form submission.
*
* Nothing is uploaded — this is a template demo. Selected files are listed
* locally and can be removed.
*/
export interface FileUploadProps {
name?: string
id?: string
accept?: string
multiple?: boolean
disabled?: boolean
/** Human-readable constraint, e.g. "PNG or PDF, up to 10 MB". */
hint?: string
maxSizeBytes?: number
className?: string
'aria-describedby'?: string
onFilesChange?: (files: File[]) => void
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function FileUpload({
name = 'files',
id = 'file-upload',
accept,
multiple = false,
disabled = false,
hint,
maxSizeBytes,
className,
onFilesChange,
...aria
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<File[]>([])
const [dragging, setDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const accept_ = (incoming: FileList | null) => {
if (!incoming) return
const next = Array.from(incoming)
const oversize = maxSizeBytes ? next.find((file) => file.size > maxSizeBytes) : undefined
if (oversize) {
setError(`${oversize.name} is larger than ${formatBytes(maxSizeBytes ?? 0)}.`)
return
}
setError(null)
const merged = multiple ? [...files, ...next] : next.slice(0, 1)
setFiles(merged)
onFilesChange?.(merged)
}
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
setDragging(false)
if (disabled) return
accept_(event.dataTransfer.files)
}
const remove = (index: number) => {
const next = files.filter((_, i) => i !== index)
setFiles(next)
onFilesChange?.(next)
if (inputRef.current) inputRef.current.value = ''
}
return (
<div className={cn('flex flex-col gap-3', className)}>
<div
onDragOver={(event) => {
event.preventDefault()
if (!disabled) setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={cn(
'rounded-md border border-dashed transition-colors duration-150 ease-standard',
dragging ? 'border-accent bg-accent-soft' : 'border-line-strong bg-surface-sunken',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
aria-describedby={aria['aria-describedby']}
className="flex w-full flex-col items-center gap-2 px-4 py-8 text-center disabled:cursor-not-allowed"
>
<UploadCloud className="size-6 text-ink-subtle" aria-hidden="true" />
<span className="text-sm font-medium text-ink">
Drop {multiple ? 'files' : 'a file'} here, or{' '}
<span className="text-accent underline underline-offset-4">browse</span>
</span>
{hint ? <span className="text-xs text-ink-muted">{hint}</span> : null}
</button>
<input
ref={inputRef}
id={id}
name={name}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="sr-only"
onChange={(event) => accept_(event.target.files)}
/>
</div>
{error ? (
<p role="alert" className="text-xs font-medium text-danger">
{error}
</p>
) : null}
{files.length > 0 ? (
<ul className="flex flex-col gap-1.5" aria-label="Selected files">
{files.map((file, index) => (
<li
key={`${file.name}-${index}`}
className="flex items-center gap-2.5 rounded-md border border-line bg-surface px-3 py-2 text-sm"
>
<FileIcon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-ink">{file.name}</span>
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
{formatBytes(file.size)}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(index)}
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" aria-hidden="true" />
</Button>
</li>
))}
</ul>
) : null}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The shape of a form that respects the applicant’s time: four short fields and one question worth answering. Salary and demographic questions are absent by design.
- Ask a specific question — “a component you are proud of” produces better answers than “why us”.
- Marking the CV optional signals that the written answer is what is actually read.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Role select
- Availability radios
- Open question
- Optional CV
Accessibility
- Optional emphasis
- The CV field is marked Optional with a reason in the help text.
- Grouped availability
- Availability is a Fieldset with a legend.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsMulti-step application
Eligibility first, so nobody completes twenty questions before discovering they cannot apply.
advanced4 variantsFile upload
A drop zone that is fully operable from the keyboard, with local file listing, size validation and removal.
intermediate5 variantsPartnership enquiry
Multi-select partnership types as selectable cards, plus a substantive proposal field.
starter3 variants