Skip to content
UI Kit
Accent color

Application form

Applying for a role, a shift or a programme. CV checked on the way in, a letter as file or written in place, the privacy note says how long data is kept, and every send names what is missing.

One of 3 components in Applications and sign-ups

4 variants

Variants

Job application

The role stays in view beside the form with its facts and what the job is about. CV as a checked PDF, the letter written in place or uploaded, earliest start, salary, a link and the privacy note with retention.

Open in Studio

Quick apply

For shifts and hourly jobs, made for the phone. Name and number in large fields, days and shifts as chips, CV only if there is one. The whole form fits a minute.

Open in Studio

Guided letter

Three short prompts with a word count each instead of a blank page. Beside them the answers become a letter while typing, every paragraph rises in, the send button sits under the letter.

Open in Studio

Eligibility first

Three yes or no questions before anything else. An answer that does not fit explains itself on the spot, and only when everything fits does the application open with a soft height.

Open in Studio

Details

What Application form brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/application-form.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputtextareacheckboxfile-uploaddate-input
npm packages
None
Languages
English and German, 57 texts of its own

Props

The ApplicationFormProps interface from components/ui/application-form.tsx, with the comments from the code.

  • variantoptional
    ApplicationFormVariant

    jobquick-applyguided-lettereligibility

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • roleoptional
    ApplicationRole
  • daysoptional
    string[]

    quick-apply: days and shifts to pick

  • shiftsoptional
    string[]
  • promptsoptional
    ApplicationPrompt[]

    guided-letter: the prompts

  • checksoptional
    EligibilityQuestion[]

    eligibility: the questions before the form

  • retentionoptional
    string

    How long applications are kept, shown in the privacy note, e.g. "six months"

  • privacyHrefoptional
    string

    Link to the privacy policy

  • todayoptional
    string
  • stepsoptional
    DoneStep[]
  • submitLabeloptional
    string
  • doneoptional
    { title: string; text?: string }
  • onSubmitoptional
    (data: Record<string, unknown> & { files?: File[] }) => void

Built-in texts

Application form writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 57 texts
youEnglish About youGerman Zu dir
firstNameEnglish First nameGerman Vorname
lastNameEnglish Last nameGerman Nachname
nameEnglish NameGerman Name
nameNeededEnglish Please tell us your name so we know who applies.German Bitte nenne uns deinen Namen, damit wir wissen, wer sich bewirbt.
emailEnglish EmailGerman E-Mail
emailNeededEnglish This address looks incomplete. Please use a form like name@example.com so our answer reaches you.German Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit unsere Antwort dich erreicht.
phoneEnglish PhoneGerman Telefon
phoneNeededEnglish Please add a number with area code, we call back within two days.German Bitte gib eine Nummer mit Vorwahl an, wir rufen innerhalb von zwei Tagen zurück.
documentsEnglish DocumentsGerman Unterlagen
cvEnglish CVGerman Lebenslauf
cvHintEnglish PDF up to 10 MBGerman PDF bis 10 MB
cvNeededEnglish Please add your CV as a PDF, it is the one document we need.German Bitte füge deinen Lebenslauf als PDF hinzu, nur diese Unterlage brauchen wir.
letterEnglish Cover letterGerman Anschreiben
letterUploadEnglish Upload a fileGerman Datei hochladen
letterWriteEnglish Write it hereGerman Hier schreiben
letterPlaceholderEnglish A few lines on why this role and why youGerman Ein paar Zeilen, warum diese Stelle und warum du
optionalEnglish optionalGerman optional
moreEnglish A few more thingsGerman Noch ein paar Dinge
startEnglish Earliest startGerman Frühester Start
salaryEnglish Salary expectation per yearGerman Gehaltswunsch pro Jahr
linksEnglish Portfolio or profileGerman Portfolio oder Profil
linksHintEnglish A link to your work, if you have oneGerman Ein Link zu deiner Arbeit, falls vorhanden
privacyEnglish I agree that my application is stored and read for this role.German Ich bin einverstanden, dass meine Bewerbung für diese Stelle gespeichert und gelesen wird.
privacyHintEnglish We keep it for {r} and delete it after that, unless you allow more.German Wir bewahren sie {r} auf und löschen sie danach, außer du erlaubst mehr.
privacyLinkEnglish Privacy policyGerman Datenschutzerklärung
retentionEnglish six monthsGerman sechs Monate
privacyNeededEnglish Please agree so we are allowed to read your application.German Bitte stimme zu, damit wir deine Bewerbung lesen dürfen.
poolEnglish Keep me in mind for other roles for one yearGerman Für andere Stellen ein Jahr lang an mich denken
sendEnglish Send applicationGerman Bewerbung senden
roleEnglish The roleGerman Die Stelle
daysEnglish Which days suit you?German Welche Tage passen dir?
daysNeededEnglish Please choose at least one day, so we can plan you in.German Bitte wähle mindestens einen Tag, damit wir dich einplanen können.
shiftsEnglish Which shifts?German Welche Schichten?
shiftsNeededEnglish Please choose at least one shift.German Bitte wähle mindestens eine Schicht.
cvOptionalEnglish CV, if you have oneGerman Lebenslauf, falls vorhanden
quickNoteEnglish Takes about a minute. We call you back within two days.German Dauert etwa eine Minute. Wir rufen innerhalb von zwei Tagen zurück.
wordsEnglish {n} of {max} wordsGerman {n} von {max} Wörtern
tooLongEnglish {n} words over, please shorten it a little.German {n} Wörter zu viel, bitte kürze ein wenig.
promptNeededEnglish Please write a few words here, it is part of the letter.German Bitte schreib hier ein paar Worte, sie gehören zum Anschreiben.
previewEnglish Your letterGerman Dein Anschreiben
previewEmptyEnglish Your answers become a letter here as you write.German Aus deinen Antworten entsteht hier beim Schreiben ein Anschreiben.
greetingEnglish Dear hiring team,German Liebes Team,
closingEnglish Kind regardsGerman Viele Grüße
checkEnglish Before you startGerman Bevor du startest
checkTextEnglish Three questions to see if this programme fits you.German Drei Fragen, ob das Programm zu dir passt.
yesEnglish YesGerman Ja
noEnglish NoGerman Nein
fitsTitleEnglish You fit, welcome to applyGerman Du passt, bewirb dich gern
fitsTextEnglish The application opens below.German Die Bewerbung öffnet sich darunter.
notYetTitleEnglish This round is not the right one yetGerman Diese Runde passt noch nicht
notYetTextEnglish Change an answer if you chose by mistake.German Ändere eine Antwort, falls du dich verklickt hast.
motivationEnglish Why do you want to take part?German Warum möchtest du teilnehmen?
motivationNeededEnglish Please tell us in a few sentences why, it is what the jury reads first.German Bitte schreib in ein paar Sätzen, warum, das liest die Jury zuerst.
doneTitleEnglish Thank you, your application has arrivedGerman Danke, deine Bewerbung ist angekommen
doneTextEnglish You get a confirmation by email. We answer every application within two weeks.German Du bekommst eine Bestätigung per E-Mail. Wir antworten auf jede Bewerbung innerhalb von zwei Wochen.
againEnglish Back to the startGerman Zurück zum Anfang

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • Uses the roles p, radiogroup and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 745 lines
components/ui/application-form.tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { FileUpload, type UploadItem } from '@/components/ui/file-upload';
import { DateInput } from '@/components/ui/date-input';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { ChipGroup, DonePanel, ErrorSummary, FlowHead, isEmail, isPhone, Unfold, useFocusFirstError, type DoneStep, summaryOf, useFormState } from '@/components/ui/form-flow';

/*
 * Applying for a job, a shift or a programme. Files are checked on the way
 * in, the privacy note says how long data is kept, and every send names what
 * is missing and focuses it. Sends everything to `onSubmit`, files as File
 * objects in `files`.
 *
 *   job            a full application beside the role, CV and letter as files or written
 *   quick-apply    a minute on the phone, days and shifts as chips, CV optional
 *   guided-letter  three short prompts become a letter, the preview builds beside them
 *   eligibility    three yes or no questions first, the form opens when they fit
 */

export type ApplicationFormVariant = 'job' | 'quick-apply' | 'guided-letter' | 'eligibility';

export interface ApplicationRole {
  title: string;
  team?: string;
  location?: string;
  type?: string;
  salary?: string;
  /** Short lines under the title, e.g. what the role does */
  points?: string[];
}

export interface ApplicationPrompt {
  id: string;
  title: string;
  hint?: string;
  /** Most words for this part */
  words?: number;
}

export interface EligibilityQuestion {
  id: string;
  title: string;
  hint?: string;
  /** The answer that fits, default yes */
  fits?: 'yes' | 'no';
  /** What to show when the answer does not fit */
  explain?: string;
}

export interface ApplicationFormProps {
  variant?: ApplicationFormVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  role?: ApplicationRole;
  /** quick-apply: days and shifts to pick */
  days?: string[];
  shifts?: string[];
  /** guided-letter: the prompts */
  prompts?: ApplicationPrompt[];
  /** eligibility: the questions before the form */
  checks?: EligibilityQuestion[];
  /** How long applications are kept, shown in the privacy note, e.g. "six months" */
  retention?: string;
  /** Link to the privacy policy */
  privacyHref?: string;
  today?: string;
  steps?: DoneStep[];
  submitLabel?: string;
  done?: { title: string; text?: string };
  onSubmit?: (data: Record<string, unknown> & { files?: File[] }) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    you: 'About you',
    firstName: 'First name',
    lastName: 'Last name',
    name: 'Name',
    nameNeeded: 'Please tell us your name so we know who applies.',
    email: 'Email',
    emailNeeded: 'This address looks incomplete. Please use a form like name@example.com so our answer reaches you.',
    phone: 'Phone',
    phoneNeeded: 'Please add a number with area code, we call back within two days.',
    documents: 'Documents',
    cv: 'CV',
    cvHint: 'PDF up to 10 MB',
    cvNeeded: 'Please add your CV as a PDF, it is the one document we need.',
    letter: 'Cover letter',
    letterUpload: 'Upload a file',
    letterWrite: 'Write it here',
    letterPlaceholder: 'A few lines on why this role and why you',
    optional: 'optional',
    more: 'A few more things',
    start: 'Earliest start',
    salary: 'Salary expectation per year',
    links: 'Portfolio or profile',
    linksHint: 'A link to your work, if you have one',
    privacy: 'I agree that my application is stored and read for this role.',
    privacyHint: 'We keep it for {r} and delete it after that, unless you allow more.',
    privacyLink: 'Privacy policy',
    retention: 'six months',
    privacyNeeded: 'Please agree so we are allowed to read your application.',
    pool: 'Keep me in mind for other roles for one year',
    send: 'Send application',
    role: 'The role',
    days: 'Which days suit you?',
    daysNeeded: 'Please choose at least one day, so we can plan you in.',
    shifts: 'Which shifts?',
    shiftsNeeded: 'Please choose at least one shift.',
    cvOptional: 'CV, if you have one',
    quickNote: 'Takes about a minute. We call you back within two days.',
    words: '{n} of {max} words',
    tooLong: '{n} words over, please shorten it a little.',
    promptNeeded: 'Please write a few words here, it is part of the letter.',
    preview: 'Your letter',
    previewEmpty: 'Your answers become a letter here as you write.',
    greeting: 'Dear hiring team,',
    closing: 'Kind regards',
    check: 'Before you start',
    checkText: 'Three questions to see if this programme fits you.',
    yes: 'Yes',
    no: 'No',
    fitsTitle: 'You fit, welcome to apply',
    fitsText: 'The application opens below.',
    notYetTitle: 'This round is not the right one yet',
    notYetText: 'Change an answer if you chose by mistake.',
    motivation: 'Why do you want to take part?',
    motivationNeeded: 'Please tell us in a few sentences why, it is what the jury reads first.',
    doneTitle: 'Thank you, your application has arrived',
    doneText: 'You get a confirmation by email. We answer every application within two weeks.',
    again: 'Back to the start',
  },
  de: {
    you: 'Zu dir',
    firstName: 'Vorname',
    lastName: 'Nachname',
    name: 'Name',
    nameNeeded: 'Bitte nenne uns deinen Namen, damit wir wissen, wer sich bewirbt.',
    email: 'E-Mail',
    emailNeeded: 'Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit unsere Antwort dich erreicht.',
    phone: 'Telefon',
    phoneNeeded: 'Bitte gib eine Nummer mit Vorwahl an, wir rufen innerhalb von zwei Tagen zurück.',
    documents: 'Unterlagen',
    cv: 'Lebenslauf',
    cvHint: 'PDF bis 10 MB',
    cvNeeded: 'Bitte füge deinen Lebenslauf als PDF hinzu, nur diese Unterlage brauchen wir.',
    letter: 'Anschreiben',
    letterUpload: 'Datei hochladen',
    letterWrite: 'Hier schreiben',
    letterPlaceholder: 'Ein paar Zeilen, warum diese Stelle und warum du',
    optional: 'optional',
    more: 'Noch ein paar Dinge',
    start: 'Frühester Start',
    salary: 'Gehaltswunsch pro Jahr',
    links: 'Portfolio oder Profil',
    linksHint: 'Ein Link zu deiner Arbeit, falls vorhanden',
    privacy: 'Ich bin einverstanden, dass meine Bewerbung für diese Stelle gespeichert und gelesen wird.',
    privacyHint: 'Wir bewahren sie {r} auf und löschen sie danach, außer du erlaubst mehr.',
    privacyLink: 'Datenschutzerklärung',
    retention: 'sechs Monate',
    privacyNeeded: 'Bitte stimme zu, damit wir deine Bewerbung lesen dürfen.',
    pool: 'Für andere Stellen ein Jahr lang an mich denken',
    send: 'Bewerbung senden',
    role: 'Die Stelle',
    days: 'Welche Tage passen dir?',
    daysNeeded: 'Bitte wähle mindestens einen Tag, damit wir dich einplanen können.',
    shifts: 'Welche Schichten?',
    shiftsNeeded: 'Bitte wähle mindestens eine Schicht.',
    cvOptional: 'Lebenslauf, falls vorhanden',
    quickNote: 'Dauert etwa eine Minute. Wir rufen innerhalb von zwei Tagen zurück.',
    words: '{n} von {max} Wörtern',
    tooLong: '{n} Wörter zu viel, bitte kürze ein wenig.',
    promptNeeded: 'Bitte schreib hier ein paar Worte, sie gehören zum Anschreiben.',
    preview: 'Dein Anschreiben',
    previewEmpty: 'Aus deinen Antworten entsteht hier beim Schreiben ein Anschreiben.',
    greeting: 'Liebes Team,',
    closing: 'Viele Grüße',
    check: 'Bevor du startest',
    checkText: 'Drei Fragen, ob das Programm zu dir passt.',
    yes: 'Ja',
    no: 'Nein',
    fitsTitle: 'Du passt, bewirb dich gern',
    fitsText: 'Die Bewerbung öffnet sich darunter.',
    notYetTitle: 'Diese Runde passt noch nicht',
    notYetText: 'Ändere eine Antwort, falls du dich verklickt hast.',
    motivation: 'Warum möchtest du teilnehmen?',
    motivationNeeded: 'Bitte schreib in ein paar Sätzen, warum, das liest die Jury zuerst.',
    doneTitle: 'Danke, deine Bewerbung ist angekommen',
    doneText: 'Du bekommst eine Bestätigung per E-Mail. Wir antworten auf jede Bewerbung innerhalb von zwei Wochen.',
    again: 'Zurück zum Anfang',
  },
};

type Texts = (typeof TEXTS)['en'];

/** A label with the required star in the kit colour */
function Req({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
        *
      </span>
    </>
  );
}

const asItems = (files: File[], prefix: string): UploadItem[] => files.map((f, i) => ({ id: `${prefix}-${i}-${f.name}`, name: f.name, size: f.size, status: 'done' }));

function Done({ p, t, onAgain, className }: { p: ApplicationFormProps; t: Texts; onAgain: () => void; className?: string }) {
  return (
    <div className={cn('kit-card mx-auto p-6 sm:p-10', className)}>
      <DonePanel
        title={p.done?.title ?? t.doneTitle}
        text={p.done?.text ?? t.doneText}
        steps={p.steps}
        actions={
          <Button variant="outline" iconLeft="rotate-ccw" onClick={onAgain}>
            {t.again}
          </Button>
        }
      />
    </div>
  );
}

function Privacy({ id, p, t, f, value, set }: { id: string; p: ApplicationFormProps; t: Texts; f: ReturnType<typeof useFormState>; value: boolean; set: (v: boolean) => void }) {
  return (
    <div>
      <Checkbox
        id={`${id}-privacy`}
        label={t.privacy}
        description={
          <>
            {t.privacyHint.replace('{r}', p.retention ?? t.retention)}{' '}
            {p.privacyHref && (
              <a href={p.privacyHref} className="font-medium text-foreground underline underline-offset-4 hover:text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                {t.privacyLink}
              </a>
            )}
          </>
        }
        checked={value}
        onChange={(e) => (set(e.target.checked), f.clear('privacy'))}
        error={f.errors.privacy}
      />
    </div>
  );
}

function RoleCard({ role, t }: { role: ApplicationRole; t: Texts }) {
  const facts: [IconName, string | undefined][] = [
    ['map-pin', role.location],
    ['clock', role.type],
    ['users', role.team],
    ['coins', role.salary],
  ];
  return (
    <div className="kit-card overflow-hidden">
      <div className="border-b border-border bg-surface px-6 py-5">
        <p className="text-[12.5px] font-medium text-muted-foreground">{t.role}</p>
        <p className="mt-1 text-[19px] font-semibold leading-snug tracking-[-0.02em] text-foreground">{role.title}</p>
      </div>
      <ul className="grid gap-3 px-6 py-5">
        {facts
          .filter(([, v]) => v)
          .map(([icon, v]) => (
            <li key={icon} className="flex items-center gap-3 text-[14px] text-foreground">
              <Icon name={icon} size={16} className="shrink-0 text-muted-foreground" />
              {v}
            </li>
          ))}
      </ul>
      {role.points && role.points.length > 0 && (
        <Stagger step={80} as="ul" className="grid gap-2.5 border-t border-border px-6 py-5">
          {role.points.map((x) => (
            <li key={x} className="flex items-start gap-2.5 text-[13.5px] leading-snug text-muted-foreground">
              <Icon name="check" size={14} className="mt-0.5 shrink-0 text-success" />
              {x}
            </li>
          ))}
        </Stagger>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* job                                                                 */
/* ------------------------------------------------------------------ */

function Job({ p, t }: { p: ApplicationFormProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const [v, setV] = useState({ first: '', last: '', email: '', phone: '', salary: '', link: '', letter: '' });
  const [start, setStart] = useState<string | null>(null);
  const [cv, setCv] = useState<File[]>([]);
  const [letterFile, setLetterFile] = useState<File[]>([]);
  const [letterMode, setLetterMode] = useState<'upload' | 'write'>('write');
  const [privacy, setPrivacy] = useState(false);
  const [pool, setPool] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const set = (k: keyof typeof v) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => (setV((x) => ({ ...x, [k]: e.target.value })), f.clear(k));
  const summary = summaryOf(f.errors, [
    ['first', `${id}-first`, t.firstName],
    ['email', `${id}-email`, t.email],
    ['cv', `${id}-cv`, t.cv],
    ['privacy', `${id}-privacy`, t.privacyLink],
  ]);
  if (f.done) return <Done p={p} t={t} onAgain={() => (setCv([]), setPrivacy(false), f.setDone(false))} className="max-w-[48rem]" />;
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[19rem_minmax(0,1fr)] lg:gap-10">
      {p.role && (
        <Reveal effect="rise" className="lg:sticky lg:top-24 lg:self-start">
          <RoleCard role={p.role} t={t} />
        </Reveal>
      )}
      <Reveal effect="rise" delay={80} className="min-w-0">
        <form
          ref={formRef}
          noValidate
          className="kit-card grid gap-8 p-6 sm:p-8"
          onSubmit={(e) => {
            e.preventDefault();
            const found: Record<string, string> = {};
            if (!v.first.trim()) found.first = t.nameNeeded;
            if (!isEmail(v.email)) found.email = t.emailNeeded;
            if (!cv.length) found.cv = t.cvNeeded;
            if (!privacy) found.privacy = t.privacyNeeded;
            if (f.fail(found)) return;
            p.onSubmit?.({ ...v, start, pool, files: [...cv, ...letterFile] });
            f.setDone(true);
          }}
        >
          <ErrorSummary ref={summaryRef} errors={summary} />
          <fieldset className="grid min-w-0 grid-cols-1 gap-5 sm:grid-cols-2">
            <legend className="mb-4 text-[15px] font-semibold text-foreground">{t.you}</legend>
            <Input id={`${id}-first`} label={t.firstName} required autoComplete="given-name" value={v.first} onChange={set('first')} error={f.errors.first} />
            <Input label={t.lastName} autoComplete="family-name" value={v.last} onChange={set('last')} />
            <Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={set('email')} error={f.errors.email} />
            <Input type="tel" inputMode="tel" label={t.phone} optional autoComplete="tel" value={v.phone} onChange={set('phone')} />
          </fieldset>
          <div className="border-t border-border pt-7"><fieldset className="grid min-w-0 gap-5">
            <legend className="mb-4 text-[15px] font-semibold text-foreground">{t.documents}</legend>
            <div id={`${id}-cv`}>
              <FileUpload label={<Req>{t.cv}</Req>} hint={t.cvHint} accept=".pdf" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(cv, 'cv')} onFiles={(x) => (setCv(x.slice(0, 1)), f.clear('cv'))} onRemove={() => setCv([])} error={f.errors.cv} />
            </div>
            <div>
              <div className="flex flex-wrap items-center justify-between gap-3">
                <p className="text-sm font-medium text-foreground">
                  {t.letter} <span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>
                </p>
                {/* Upload or write, a small switch with a sliding tile */}
                <div role="radiogroup" aria-label={t.letter} className="relative grid grid-cols-2 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-0.5">
                  <span aria-hidden className={cn('absolute inset-y-0.5 start-0.5 w-[calc(50%-0.125rem)] rounded-md bg-card shadow-[var(--shadow-sm)] transition-[translate] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none', letterMode === 'upload' && 'translate-x-full rtl:-translate-x-full')} />
                  {(['write', 'upload'] as const).map((m) => (
                    <label key={m} className="relative z-10 flex min-h-11 cursor-pointer items-center justify-center rounded-md px-3 text-[13px] font-medium text-muted-foreground transition-colors has-[:checked]:text-foreground has-[:focus-visible]:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] sm:min-h-9">
                      <input type="radio" name={`${id}-lm`} checked={letterMode === m} onChange={() => setLetterMode(m)} className="sr-only" />
                      {m === 'write' ? t.letterWrite : t.letterUpload}
                    </label>
                  ))}
                </div>
              </div>
              <div key={letterMode} className="kit-rise mt-3">
                {letterMode === 'write' ? (
                  <Textarea aria-label={t.letter} placeholder={t.letterPlaceholder} value={v.letter} onChange={set('letter')} minRows={5} maxLength={3000} />
                ) : (
                  <FileUpload variant="compact" accept=".pdf,.doc,.docx" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(letterFile, 'letter')} onFiles={(x) => setLetterFile(x.slice(0, 1))} onRemove={() => setLetterFile([])} />
                )}
              </div>
            </div>
          </fieldset></div>
          <div className="border-t border-border pt-7"><fieldset className="grid min-w-0 grid-cols-1 gap-5 sm:grid-cols-2">
            <legend className="mb-4 text-[15px] font-semibold text-foreground">{t.more}</legend>
            <DateInput label={t.start} optional today={p.today} min={p.today} value={start} onValueChange={setStart} />
            <Input label={t.salary} optional inputMode="numeric" value={v.salary} onChange={set('salary')} />
            <Input type="url" label={t.links} optional hint={t.linksHint} placeholder="https://" value={v.link} onChange={set('link')} className="sm:col-span-2" />
          </fieldset></div>
          <div className="grid gap-4 border-t border-border pt-7">
            <Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
            <Checkbox label={t.pool} checked={pool} onChange={(e) => setPool(e.target.checked)} />
          </div>
          <div className="flex justify-end">
            <Button type="submit" icon="send" magnetic className="max-sm:w-full">
              {p.submitLabel ?? t.send}
            </Button>
          </div>
        </form>
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* quick-apply                                                         */
/* ------------------------------------------------------------------ */

function QuickApply({ p, t }: { p: ApplicationFormProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const [v, setV] = useState({ name: '', phone: '' });
  const [days, setDays] = useState<string[]>([]);
  const [shifts, setShifts] = useState<string[]>([]);
  const [cv, setCv] = useState<File[]>([]);
  const [privacy, setPrivacy] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const summary = summaryOf(f.errors, [
    ['name', `${id}-name`, t.name],
    ['phone', `${id}-phone`, t.phone],
    ['days', `${id}-days`, t.days],
    ['shifts', `${id}-shifts`, t.shifts],
    ['privacy', `${id}-privacy`, t.privacyLink],
  ]);
  if (f.done) return <Done p={p} t={t} onAgain={() => (setDays([]), setShifts([]), setPrivacy(false), f.setDone(false))} className="max-w-[40rem]" />;
  return (
    <Reveal effect="scale" className="mx-auto max-w-[40rem]">
      <form
        ref={formRef}
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!v.name.trim()) found.name = t.nameNeeded;
          if (!isPhone(v.phone)) found.phone = t.phoneNeeded;
          if (!days.length) found.days = t.daysNeeded;
          if (!shifts.length) found.shifts = t.shiftsNeeded;
          if (!privacy) found.privacy = t.privacyNeeded;
          if (f.fail(found)) return;
          p.onSubmit?.({ ...v, days, shifts, files: cv });
          f.setDone(true);
        }}
      >
        {p.role && (
          <div className="flex items-center gap-4 border-b border-border bg-surface px-6 py-4 sm:px-8">
            <span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
              <Icon name="briefcase" size={20} />
            </span>
            <span className="min-w-0">
              <span className="block truncate text-[16px] font-semibold text-foreground">{p.role.title}</span>
              <span className="block text-[13px] leading-snug text-muted-foreground">{[p.role.location, p.role.type, p.role.salary].filter(Boolean).join(' · ')}</span>
            </span>
          </div>
        )}
        <div className="grid gap-6 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
            <Input id={`${id}-name`} label={t.name} required autoComplete="name" size="lg" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
            <Input id={`${id}-phone`} type="tel" inputMode="tel" label={t.phone} required autoComplete="tel" size="lg" value={v.phone} onChange={(e) => (setV((x) => ({ ...x, phone: e.target.value })), f.clear('phone'))} error={f.errors.phone} />
          </div>
          <ChipGroup id={`${id}-days`} legend={t.days} required multiple options={(p.days ?? []).map((d) => ({ value: d, label: d }))} value={days} onChange={(x) => (setDays(x), f.clear('days'))} error={f.errors.days} layout={(p.days?.length ?? 0) === 7 ? 7 : 'wrap'} />
          <ChipGroup id={`${id}-shifts`} legend={t.shifts} required multiple options={(p.shifts ?? []).map((s) => ({ value: s, label: s, icon: 'clock' as IconName }))} value={shifts} onChange={(x) => (setShifts(x), f.clear('shifts'))} error={f.errors.shifts} layout={(p.shifts?.length ?? 0) === 3 ? 3 : 'wrap'} />
          <FileUpload variant="compact" label={t.cvOptional} hint={t.cvHint} accept=".pdf,image/*" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(cv, 'cv')} onFiles={(x) => setCv(x.slice(0, 1))} onRemove={() => setCv([])} />
          <Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
          <Button type="submit" size="lg" icon="send" className="w-full">
            {p.submitLabel ?? t.send}
          </Button>
          <p className="-mt-2 text-center text-[13px] text-muted-foreground">{t.quickNote}</p>
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* guided-letter                                                       */
/* ------------------------------------------------------------------ */

const countWords = (s: string) => (s.trim() ? s.trim().split(/\s+/).length : 0);

function GuidedLetter({ p, t }: { p: ApplicationFormProps; t: Texts }) {
  const id = useId();
  const locale = useLocale();
  const f = useFormState();
  const prompts = p.prompts ?? [];
  const [answers, setAnswers] = useState<Record<string, string>>({});
  const [who, setWho] = useState({ name: '', email: '' });
  const [cv, setCv] = useState<File[]>([]);
  const [privacy, setPrivacy] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const parts = prompts.map((q) => answers[q.id]?.trim()).filter(Boolean) as string[];
  const summary = summaryOf(f.errors, [
    ...prompts.map((q) => [q.id, `${id}-${q.id}`, q.title] as [string, string, string]),
    ['name', `${id}-name`, t.name],
    ['email', `${id}-email`, t.email],
    ['cv', `${id}-cv`, t.cv],
    ['privacy', `${id}-privacy`, t.privacyLink],
  ]);
  if (f.done) return <Done p={p} t={t} onAgain={() => (setAnswers({}), setPrivacy(false), f.setDone(false))} className="max-w-[48rem]" />;
  return (
    <form
      ref={formRef}
      noValidate
      className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)] lg:gap-10"
      onSubmit={(e) => {
        e.preventDefault();
        const found: Record<string, string> = {};
        for (const q of prompts) {
          const n = countWords(answers[q.id] ?? '');
          if (!n) found[q.id] = t.promptNeeded;
          else if (q.words && n > q.words) found[q.id] = t.tooLong.replace('{n}', String(n - q.words));
        }
        if (!who.name.trim()) found.name = t.nameNeeded;
        if (!isEmail(who.email)) found.email = t.emailNeeded;
        if (!cv.length) found.cv = t.cvNeeded;
        if (!privacy) found.privacy = t.privacyNeeded;
        if (f.fail(found)) return;
        p.onSubmit?.({ answers, ...who, files: cv });
        f.setDone(true);
      }}
    >
      <div className="grid min-w-0 gap-5">
        <ErrorSummary ref={summaryRef} errors={summary} />
        <Stagger step={100} as="ol" className="grid gap-4">
          {prompts.map((q, i) => {
            const n = countWords(answers[q.id] ?? '');
            const over = q.words !== undefined && n > q.words;
            return (
              <li key={q.id} className="kit-card p-5 sm:p-6">
                <div className="flex items-baseline gap-3">
                  <span aria-hidden className="text-[13px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                    {i + 1}
                  </span>
                  <div className="min-w-0 flex-1">
                    <Textarea
                      id={`${id}-${q.id}`}
                      label={q.title}
                      required
                      hint={f.errors[q.id] ? undefined : q.hint}
                      error={f.errors[q.id]}
                      value={answers[q.id] ?? ''}
                      onChange={(e) => (setAnswers((a) => ({ ...a, [q.id]: e.target.value })), f.clear(q.id))}
                      minRows={3}
                      invalid={over}
                      footer={
                        q.words ? (
                          <span className={cn('text-xs tabular-nums transition-colors', over ? 'font-medium text-destructive' : n > q.words * 0.85 ? 'text-foreground' : 'text-subtle-foreground')}>
                            {t.words.replace('{n}', n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')).replace('{max}', String(q.words))}
                          </span>
                        ) : undefined
                      }
                    />
                  </div>
                </div>
              </li>
            );
          })}
        </Stagger>
        <div className="kit-card grid grid-cols-1 gap-5 p-5 sm:grid-cols-2 sm:p-6">
          <Input id={`${id}-name`} label={t.name} required autoComplete="name" value={who.name} onChange={(e) => (setWho((w) => ({ ...w, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
          <Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={who.email} onChange={(e) => (setWho((w) => ({ ...w, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
          <div id={`${id}-cv`} className="sm:col-span-2">
            <FileUpload variant="compact" label={<Req>{t.cv}</Req>} hint={t.cvHint} accept=".pdf" maxSize={10e6} maxFiles={1} multiple={false} items={asItems(cv, 'cv')} onFiles={(x) => (setCv(x.slice(0, 1)), f.clear('cv'))} onRemove={() => setCv([])} error={f.errors.cv} />
          </div>
          <div className="sm:col-span-2">
            <Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
          </div>
        </div>
      </div>
      {/* The letter builds itself from the answers, paper-like and calm */}
      <div className="min-w-0 lg:sticky lg:top-24 lg:self-start">
        <Reveal effect="rise" delay={120}>
          <div className="rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
            <p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
              <Icon name="file" size={15} />
              {t.preview}
            </p>
            <div className="mt-5 min-h-[9rem] text-pretty lg:min-h-[14rem] text-[14.5px] leading-[1.7] text-foreground">
              {parts.length ? (
                <>
                  <p>{t.greeting}</p>
                  {parts.map((x, i) => (
                    <p key={i} className="kit-rise mt-3 whitespace-pre-line">
                      {x}
                    </p>
                  ))}
                  <p className="mt-5">{t.closing}</p>
                  <p className="font-medium">{who.name || '…'}</p>
                </>
              ) : (
                <div className="grid gap-2.5">
                  <p className="text-muted-foreground">{t.previewEmpty}</p>
                  {[92, 100, 84, 96, 60].map((w, i) => (
                    <span key={i} aria-hidden className="h-2.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]" style={{ width: `${w}%` }} />
                  ))}
                </div>
              )}
            </div>
            <Button type="submit" icon="send" className="mt-7 w-full">
              {p.submitLabel ?? t.send}
            </Button>
          </div>
        </Reveal>
      </div>
    </form>
  );
}

/* ------------------------------------------------------------------ */
/* eligibility                                                         */
/* ------------------------------------------------------------------ */

function Eligibility({ p, t }: { p: ApplicationFormProps; t: Texts }) {
  const id = useId();
  const f = useFormState();
  const checks = p.checks ?? [];
  const [answers, setAnswers] = useState<Record<string, 'yes' | 'no'>>({});
  const [v, setV] = useState({ name: '', email: '', motivation: '' });
  const [files, setFiles] = useState<File[]>([]);
  const [privacy, setPrivacy] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  useFocusFirstError(formRef, f.attempt, summaryRef);
  const allAnswered = checks.every((c) => answers[c.id]);
  const misses = checks.filter((c) => answers[c.id] && answers[c.id] !== (c.fits ?? 'yes'));
  const fits = allAnswered && misses.length === 0;
  const summary = summaryOf(f.errors, [
    ['name', `${id}-name`, t.name],
    ['email', `${id}-email`, t.email],
    ['motivation', `${id}-motivation`, t.motivation],
    ['privacy', `${id}-privacy`, t.privacyLink],
  ]);
  if (f.done) return <Done p={p} t={t} onAgain={() => (setAnswers({}), setPrivacy(false), f.setDone(false))} className="max-w-[46rem]" />;
  return (
    <div className="mx-auto max-w-[46rem]">
      <Reveal effect="rise" className="kit-card p-6 sm:p-8">
        <p className="text-[17px] font-semibold text-foreground">{t.check}</p>
        <p className="mt-1 text-[14px] text-muted-foreground">{t.checkText}</p>
        <Stagger step={90} as="ol" className="mt-6 grid gap-0 border-t border-border">
          {checks.map((c, i) => {
            const a = answers[c.id];
            const miss = a && a !== (c.fits ?? 'yes');
            return (
              <li key={c.id} className="border-b border-border py-4">
                <fieldset className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                  <legend className="sr-only">{c.title}</legend>
                  <div aria-hidden className="min-w-0">
                    <p className="text-[15px] font-medium leading-snug text-foreground">
                      <span className="me-2 tabular-nums text-subtle-foreground">{i + 1}</span>
                      {c.title}
                    </p>
                    {c.hint && <p className="mt-0.5 text-[13px] text-muted-foreground">{c.hint}</p>}
                  </div>
                  <div className="flex shrink-0 gap-2">
                    {(['yes', 'no'] as const).map((x) => (
                      <label key={x} className={cn('flex min-h-11 min-w-[5.5rem] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-200 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:flex-none', a === x ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border bg-card text-foreground hover:border-border-strong')}>
                        <input type="radio" name={`${id}-${c.id}`} checked={a === x} onChange={() => setAnswers((s) => ({ ...s, [c.id]: x }))} className="sr-only" />
                        {a === x && <Icon name="check" size={15} strokeWidth={2.2} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />}
                        {x === 'yes' ? t.yes : t.no}
                      </label>
                    ))}
                  </div>
                </fieldset>
                {miss && c.explain && <p className="kit-rise mt-3 rounded-lg bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] px-3.5 py-2.5 text-[13.5px] leading-snug text-warning-foreground">{c.explain}</p>}
              </li>
            );
          })}
        </Stagger>
        {allAnswered && (
          <p role="status" className={cn('kit-rise mt-5 flex items-start gap-3 rounded-xl border p-4', fits ? 'border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))]' : 'border-border bg-surface')}>
            <Icon name={fits ? 'check-circle' : 'info'} size={20} className={cn('mt-0.5 shrink-0', fits ? 'text-success' : 'text-muted-foreground')} />
            <span>
              <span className="block text-[15px] font-semibold text-foreground">{fits ? t.fitsTitle : t.notYetTitle}</span>
              <span className="mt-0.5 block text-[13.5px] text-muted-foreground">{fits ? t.fitsText : t.notYetText}</span>
            </span>
          </p>
        )}
      </Reveal>
      {/* The application opens with a soft height once every answer fits */}
      <Unfold open={fits}>
        <form
          ref={formRef}
          noValidate
          className="kit-card mt-5 grid gap-6 p-6 sm:p-8"
          onSubmit={(e) => {
            e.preventDefault();
            const found: Record<string, string> = {};
            if (!v.name.trim()) found.name = t.nameNeeded;
            if (!isEmail(v.email)) found.email = t.emailNeeded;
            if (!v.motivation.trim()) found.motivation = t.motivationNeeded;
            if (!privacy) found.privacy = t.privacyNeeded;
            if (f.fail(found)) return;
            p.onSubmit?.({ answers, ...v, files });
            f.setDone(true);
          }}
        >
          <ErrorSummary ref={summaryRef} errors={summary} />
          <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
            <Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => (setV((x) => ({ ...x, name: e.target.value })), f.clear('name'))} error={f.errors.name} />
            <Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => (setV((x) => ({ ...x, email: e.target.value })), f.clear('email'))} error={f.errors.email} />
          </div>
          <Textarea id={`${id}-motivation`} label={t.motivation} required value={v.motivation} onChange={(e) => (setV((x) => ({ ...x, motivation: e.target.value })), f.clear('motivation'))} error={f.errors.motivation} minRows={5} maxLength={2000} />
          <FileUpload label={t.documents} hint={t.cvHint} accept=".pdf" maxSize={10e6} maxFiles={3} items={asItems(files, 'doc')} onFiles={(x) => setFiles((y) => [...y, ...x].slice(0, 3))} onRemove={(fid) => setFiles((y) => y.filter((_, i) => `doc-${i}-${y[i].name}` !== fid))} />
          <Privacy id={id} p={p} t={t} f={f} value={privacy} set={setPrivacy} />
          <div className="flex justify-end">
            <Button type="submit" icon="send" className="max-sm:w-full">
              {p.submitLabel ?? t.send}
            </Button>
          </div>
        </form>
      </Unfold>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

/**
 * Applications for roles, shifts and programmes.
 *   <ApplicationForm variant="job" role={{ title, location, type, points }} retention="six months" privacyHref="/privacy" />
 */
export function ApplicationForm(props: ApplicationFormProps) {
  const { variant = 'job', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const center = variant !== 'job';
  return (
    <Section className={className}>
      <FlowHead eyebrow={eyebrow} title={title} description={description} align={center ? 'center' : 'left'} className="mb-10 sm:mb-14" />
      {variant === 'job' && <Job p={props} t={t} />}
      {variant === 'quick-apply' && <QuickApply p={props} t={t} />}
      {variant === 'guided-letter' && <GuidedLetter p={props} t={t} />}
      {variant === 'eligibility' && <Eligibility p={props} t={t} />}
    </Section>
  );
}

Frequent questions

How do I install Application form?

With aiw add @ai-website/application-form. The command puts components/ui/application-form.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input, textarea, checkbox, file-upload and date-input. It needs no further npm packages.

Which variants does Application form have?

4 variants, Job application, Quick apply, Guided letter and Eligibility first. You pick one with the variant prop, for example variant="quick-apply".

Can I translate the texts of Application form?

Yes. Application form carries 57 texts of its own in English and German, such as “About you”, “First name” and “Last name”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Application form?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Application form with aiw_components and install it with aiw_add @ai-website/application-form. Name the variant in your prompt, for example variant="job", and the assistant writes your content straight into the props.

Can I adjust Application form without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ApplicationForm code to copy.

Often searched

Jump to

Copied to the clipboard