Zum Inhalt springen
UI Kit
Accent

Bewerbungsformular

Bewerben auf eine Stelle, eine Schicht oder ein Programm. Der Lebenslauf wird beim Hochladen geprüft, das Anschreiben als Datei oder direkt geschrieben, der Datenschutzhinweis sagt, wie lange Daten bleiben, und jedes Senden nennt, was fehlt.

Eine von 3 Components in Bewerbungen und Anmeldungen

4 Variants

Variants

Bewerbung auf eine Stelle

Die Stelle bleibt neben dem Formular im Blick, mit Eckdaten und worum es geht. Lebenslauf als geprüftes PDF, Anschreiben direkt geschrieben oder hochgeladen, frühester Start, Gehalt, ein Link und der Datenschutzhinweis mit Aufbewahrungsdauer.

Im Studio öffnen

Schnellbewerbung

Für Schichten und Aushilfen, gemacht für das Telefon. Name und Nummer in großen Feldern, Tage und Schichten als Chips, Lebenslauf nur, wenn es einen gibt. Das ganze Formular passt in eine Minute.

Im Studio öffnen

Geführtes Anschreiben

Drei kurze Fragen mit Wortzähler statt eines leeren Blatts. Daneben werden die Antworten beim Tippen zu einem Anschreiben, jeder Absatz steigt auf, der Sendeknopf steht unter dem Brief.

Im Studio öffnen

Erst die Voraussetzungen

Drei Ja-oder-Nein-Fragen vor allem anderen. Eine Antwort, die nicht passt, erklärt sich an Ort und Stelle, und erst wenn alles passt, öffnet sich die Bewerbung mit weicher Höhe.

Im Studio öffnen

Details

Was Bewerbungsformular mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/application-form.tsx
Bringt mit
utilskitbuttoniconrevealsectionmotionform-flowinputtextareacheckboxfile-uploaddate-input
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 57 eigene Texte

Props

Die Schnittstelle ApplicationFormProps aus components/ui/application-form.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Bewerbungsformular selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 57 Texte anzeigen
youEnglisch About youDeutsch Zu dir
firstNameEnglisch First nameDeutsch Vorname
lastNameEnglisch Last nameDeutsch Nachname
nameEnglisch NameDeutsch Name
nameNeededEnglisch Please tell us your name so we know who applies.Deutsch Bitte nenne uns deinen Namen, damit wir wissen, wer sich bewirbt.
emailEnglisch EmailDeutsch E-Mail
emailNeededEnglisch This address looks incomplete. Please use a form like name@example.com so our answer reaches you.Deutsch Diese Adresse sieht unvollständig aus. Bitte nutze die Form name@beispiel.de, damit unsere Antwort dich erreicht.
phoneEnglisch PhoneDeutsch Telefon
phoneNeededEnglisch Please add a number with area code, we call back within two days.Deutsch Bitte gib eine Nummer mit Vorwahl an, wir rufen innerhalb von zwei Tagen zurück.
documentsEnglisch DocumentsDeutsch Unterlagen
cvEnglisch CVDeutsch Lebenslauf
cvHintEnglisch PDF up to 10 MBDeutsch PDF bis 10 MB
cvNeededEnglisch Please add your CV as a PDF, it is the one document we need.Deutsch Bitte füge deinen Lebenslauf als PDF hinzu, nur diese Unterlage brauchen wir.
letterEnglisch Cover letterDeutsch Anschreiben
letterUploadEnglisch Upload a fileDeutsch Datei hochladen
letterWriteEnglisch Write it hereDeutsch Hier schreiben
letterPlaceholderEnglisch A few lines on why this role and why youDeutsch Ein paar Zeilen, warum diese Stelle und warum du
optionalEnglisch optionalDeutsch optional
moreEnglisch A few more thingsDeutsch Noch ein paar Dinge
startEnglisch Earliest startDeutsch Frühester Start
salaryEnglisch Salary expectation per yearDeutsch Gehaltswunsch pro Jahr
linksEnglisch Portfolio or profileDeutsch Portfolio oder Profil
linksHintEnglisch A link to your work, if you have oneDeutsch Ein Link zu deiner Arbeit, falls vorhanden
privacyEnglisch I agree that my application is stored and read for this role.Deutsch Ich bin einverstanden, dass meine Bewerbung für diese Stelle gespeichert und gelesen wird.
privacyHintEnglisch We keep it for {r} and delete it after that, unless you allow more.Deutsch Wir bewahren sie {r} auf und löschen sie danach, außer du erlaubst mehr.
privacyLinkEnglisch Privacy policyDeutsch Datenschutzerklärung
retentionEnglisch six monthsDeutsch sechs Monate
privacyNeededEnglisch Please agree so we are allowed to read your application.Deutsch Bitte stimme zu, damit wir deine Bewerbung lesen dürfen.
poolEnglisch Keep me in mind for other roles for one yearDeutsch Für andere Stellen ein Jahr lang an mich denken
sendEnglisch Send applicationDeutsch Bewerbung senden
roleEnglisch The roleDeutsch Die Stelle
daysEnglisch Which days suit you?Deutsch Welche Tage passen dir?
daysNeededEnglisch Please choose at least one day, so we can plan you in.Deutsch Bitte wähle mindestens einen Tag, damit wir dich einplanen können.
shiftsEnglisch Which shifts?Deutsch Welche Schichten?
shiftsNeededEnglisch Please choose at least one shift.Deutsch Bitte wähle mindestens eine Schicht.
cvOptionalEnglisch CV, if you have oneDeutsch Lebenslauf, falls vorhanden
quickNoteEnglisch Takes about a minute. We call you back within two days.Deutsch Dauert etwa eine Minute. Wir rufen innerhalb von zwei Tagen zurück.
wordsEnglisch {n} of {max} wordsDeutsch {n} von {max} Wörtern
tooLongEnglisch {n} words over, please shorten it a little.Deutsch {n} Wörter zu viel, bitte kürze ein wenig.
promptNeededEnglisch Please write a few words here, it is part of the letter.Deutsch Bitte schreib hier ein paar Worte, sie gehören zum Anschreiben.
previewEnglisch Your letterDeutsch Dein Anschreiben
previewEmptyEnglisch Your answers become a letter here as you write.Deutsch Aus deinen Antworten entsteht hier beim Schreiben ein Anschreiben.
greetingEnglisch Dear hiring team,Deutsch Liebes Team,
closingEnglisch Kind regardsDeutsch Viele Grüße
checkEnglisch Before you startDeutsch Bevor du startest
checkTextEnglisch Three questions to see if this programme fits you.Deutsch Drei Fragen, ob das Programm zu dir passt.
yesEnglisch YesDeutsch Ja
noEnglisch NoDeutsch Nein
fitsTitleEnglisch You fit, welcome to applyDeutsch Du passt, bewirb dich gern
fitsTextEnglisch The application opens below.Deutsch Die Bewerbung öffnet sich darunter.
notYetTitleEnglisch This round is not the right one yetDeutsch Diese Runde passt noch nicht
notYetTextEnglisch Change an answer if you chose by mistake.Deutsch Ändere eine Antwort, falls du dich verklickt hast.
motivationEnglisch Why do you want to take part?Deutsch Warum möchtest du teilnehmen?
motivationNeededEnglisch Please tell us in a few sentences why, it is what the jury reads first.Deutsch Bitte schreib in ein paar Sätzen, warum, das liest die Jury zuerst.
doneTitleEnglisch Thank you, your application has arrivedDeutsch Danke, deine Bewerbung ist angekommen
doneTextEnglisch You get a confirmation by email. We answer every application within two weeks.Deutsch Du bekommst eine Bestätigung per E-Mail. Wir antworten auf jede Bewerbung innerhalb von zwei Wochen.
againEnglisch Back to the startDeutsch Zurück zum Anfang

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen p, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 745 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Bewerbungsformular?

Mit aiw add @ai-website/application-form. Der Befehl legt die Datei components/ui/application-form.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input, textarea, checkbox, file-upload und date-input. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Bewerbungsformular?

4 Varianten, Bewerbung auf eine Stelle, Schnellbewerbung, Geführtes Anschreiben und Erst die Voraussetzungen. Du wählst sie mit der Prop variant, zum Beispiel variant="quick-apply".

Kann ich die Texte von Bewerbungsformular übersetzen?

Ja. Bewerbungsformular bringt 57 eigene Texte auf Deutsch und Englisch mit, etwa „Zu dir“, „Vorname“ und „Nachname“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Bewerbungsformular einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bewerbungsformular mit aiw_components und installieren sie mit aiw_add @ai-website/application-form. Nenn im Prompt die Variante, etwa variant="job", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Bewerbungsformular ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ApplicationForm-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert