Skip to content

Job application

One open question instead of a cover letter, with an optional CV and no demographic fields.

Businessstarterhiringapplicationcareersupload

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.

All forms