Zum Inhalt springen
UI Kit
Accent

Lebenslauf

Ein Lebenslauf als Webseite. Erfahrung auf einer Linie, die sich füllt, Stationen als Balken über die Jahre, Fähigkeiten, die zeigen, wo sie eingesetzt wurden, die Papierversion auf einer Seite und eine Kurzfassung mit zählenden Zahlen.

Eine von 3 Components in Personal Brand

5 Variants

Variants

Klassisch

Eine Seitenspalte mit Foto, Kontakt, Download, Fähigkeiten und Sprachen, die stehen bleibt. Erfahrung auf einer Linie, die sich beim Scrollen füllt, Ausbildung als Karten.

Im Studio öffnen

Zeitleiste

Jede Station als Balken über die Jahre, eine Zeile je Firma, Überschneidungen bleiben sichtbar. Balken wachsen herein, Klick oder Pfeiltasten öffnen die Station darunter. Auf dem Telefon eine Liste mit kleinen Balken.

Im Studio öffnen

Fähigkeiten

Fähigkeiten nach Gruppen mit ihrer Stufe als Punkte, die sich füllen. Wähle eine aus, die Karte daneben zeigt jede Station, in der sie eingesetzt wurde. So ist jede Stufe belegt.

Im Studio öffnen

Kompakt

Die Papierversion auf einer Seite, so wie sie gedruckt wird. Das Blatt legt sich beim Hereinscrollen ab, Download und Drucken stehen darüber.

Im Studio öffnen

Kurzfassung

Die Kurzfassung für den Anfang einer persönlichen Website. Rolle als Überschrift, Verfügbarkeit, Zahlen, die hochzählen, die aktuelle Station und frühere auf einer Linie, die sich füllt.

Im Studio öffnen

Details

Was Lebenslauf mitbringt, aus Registry und Quellcode gelesen.

Bereich
Industries
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/resume.tsx
Bringt mit
utilskitbuttoniconmotionnumber-tickerrevealsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 25 eigene Texte

Props

Die Schnittstelle ResumeProps aus components/ui/resume.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ResumeVariant

    classictimelineskillscompactsummary

  • eyebrowoptional
    React.ReactNode

    Optional heading above the variant

  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • personPflicht
    ResumePerson
  • experienceoptional
    ResumeRole[]
  • educationoptional
    ResumeEducation[]
  • skillsoptional
    ResumeSkill[]
  • languagesoptional
    { name: string; level: string }[]
  • statsoptional
    { value: number; suffix?: string; label: string }[]

    summary: numbers, e.g. { value: 12, suffix: '+', label: 'years in product design' }

  • downloadoptional
    { href: string; size?: string }

    The PDF to download

  • linksoptional
    { label: string; href: string; icon?: IconName }[]

    Extra links next to the download, e.g. portfolio

Eingebaute Texte

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

Alle 25 Texte anzeigen
experienceEnglisch ExperienceDeutsch Berufserfahrung
educationEnglisch EducationDeutsch Ausbildung
skillsEnglisch SkillsDeutsch Fähigkeiten
languagesEnglisch LanguagesDeutsch Sprachen
contactEnglisch ContactDeutsch Kontakt
presentEnglisch todayDeutsch heute
toEnglisch toDeutsch bis
yearsEnglisch {n} yrsDeutsch {n} J.
yearEnglisch 1 yrDeutsch 1 J.
monthsEnglisch {n} mosDeutsch {n} Mon.
monthEnglisch 1 moDeutsch 1 Mon.
downloadEnglisch Download PDFDeutsch PDF herunterladen
printEnglisch PrintDeutsch Drucken
currentEnglisch NowDeutsch Aktuell
usedInEnglisch Used inDeutsch Eingesetzt bei
pickEnglisch Pick a skill to see where it was usedDeutsch Wähle eine Fähigkeit, um zu sehen, wo sie zum Einsatz kam
rolesEnglisch {n} rolesDeutsch {n} Stationen
role1Englisch 1 roleDeutsch 1 Station
levelEnglisch Level {n} of 5Deutsch Stufe {n} von 5
allEnglisch AllDeutsch Alle
groupsEnglisch Skill groupsDeutsch Gruppen
detailsEnglisch Details of the selected roleDeutsch Details zur gewählten Station
sinceEnglisch since {d}Deutsch seit {d}
nowAtEnglisch CurrentlyDeutsch Derzeit
totalEnglisch In totalDeutsch Insgesamt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Lebenslauf reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-controls, aria-label, aria-live, aria-orientation, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, img, listbox und option.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • 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).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 863 Zeilen
components/ui/resume.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Magnetic, Parallax, Stagger, useScrollFade, useScrollProgress } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Section, SectionHeader } from '@/components/ui/section';

/*
 * A résumé as a web page: experience, education, skills, languages and a
 * download of the PDF. Dates are given as YYYY-MM, a missing end means
 * the role is current. Durations are worked out from the dates.
 *
 *   classic    side column with photo, contact, skills and languages, experience on a line that fills while scrolling
 *   timeline   every role as a bar over the years, click one to read it, a list on phones
 *   skills     skills by group with their level, pick one to see where it was used
 *   compact    the one-page paper version, with download and print
 *   summary    the short version on top of a page, numbers that count up, current role and availability
 */

export type ResumeVariant = 'classic' | 'timeline' | 'skills' | 'compact' | 'summary';

export interface ResumePerson {
  name: string;
  role: string;
  summary?: string;
  location?: string;
  email?: string;
  phone?: string;
  website?: string;
  photo?: React.ReactNode;
  /** A short availability line with a live dot, e.g. "Open to new roles from April" */
  available?: string;
}

export interface ResumeRole {
  id: string;
  role: string;
  company: string;
  place?: string;
  /** YYYY-MM */
  start: string;
  /** YYYY-MM, leave out for the current role */
  end?: string;
  summary?: string;
  points?: string[];
  /** Skill names used in this role, they link the skills variant to the roles */
  skills?: string[];
  /** e.g. "Freelance" or "Part time", shown as a quiet tag */
  kind?: string;
}

export interface ResumeEducation {
  id: string;
  title: string;
  school: string;
  start: string;
  end?: string;
  note?: string;
}

export interface ResumeSkill {
  name: string;
  /** 1 to 5 */
  level: number;
  group: string;
}

export interface ResumeProps {
  variant?: ResumeVariant;
  /** Optional heading above the variant */
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  person: ResumePerson;
  experience?: ResumeRole[];
  education?: ResumeEducation[];
  skills?: ResumeSkill[];
  languages?: { name: string; level: string }[];
  /** summary: numbers, e.g. { value: 12, suffix: '+', label: 'years in product design' } */
  stats?: { value: number; suffix?: string; label: string }[];
  /** The PDF to download */
  download?: { href: string; size?: string };
  /** Extra links next to the download, e.g. portfolio */
  links?: { label: string; href: string; icon?: IconName }[];
  labels?: Partial<Texts>;
  className?: string;
}



const TEXTS = {
  en: {
    experience: 'Experience',
    education: 'Education',
    skills: 'Skills',
    languages: 'Languages',
    contact: 'Contact',
    present: 'today',
    to: 'to',
    years: '{n} yrs',
    year: '1 yr',
    months: '{n} mos',
    month: '1 mo',
    download: 'Download PDF',
    print: 'Print',
    current: 'Now',
    usedIn: 'Used in',
    pick: 'Pick a skill to see where it was used',
    roles: '{n} roles',
    role1: '1 role',
    level: 'Level {n} of 5',
    all: 'All',
    groups: 'Skill groups',
    details: 'Details of the selected role',
    since: 'since {d}',
    nowAt: 'Currently',
    total: 'In total',
  },
  de: {
    experience: 'Berufserfahrung',
    education: 'Ausbildung',
    skills: 'Fähigkeiten',
    languages: 'Sprachen',
    contact: 'Kontakt',
    present: 'heute',
    to: 'bis',
    years: '{n} J.',
    year: '1 J.',
    months: '{n} Mon.',
    month: '1 Mon.',
    download: 'PDF herunterladen',
    print: 'Drucken',
    current: 'Aktuell',
    usedIn: 'Eingesetzt bei',
    pick: 'Wähle eine Fähigkeit, um zu sehen, wo sie zum Einsatz kam',
    roles: '{n} Stationen',
    role1: '1 Station',
    level: 'Stufe {n} von 5',
    all: 'Alle',
    groups: 'Gruppen',
    details: 'Details zur gewählten Station',
    since: 'seit {d}',
    nowAt: 'Derzeit',
    total: 'Insgesamt',
  },
};
type Texts = typeof TEXTS.en;

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const fit = '[&>*]:size-full [&_img]:object-cover';
/** A fixed "today" would age, the current month is only used in the browser. On the server roles end with the latest given date. */
const ym = (s: string) => {
  const [y, m] = s.split('-').map(Number);
  return { y, m: m || 1 };
};
const monthIndex = (s: string) => {
  const { y, m } = ym(s);
  return y * 12 + (m - 1);
};
function useNow(fallback: string) {
  const [now, setNow] = useState(fallback);
  useEffect(() => {
    const d = new Date();
    setNow(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
  }, []);
  return now;
}
const latest = (roles: { start: string; end?: string }[]) => roles.reduce((a, r) => (monthIndex(r.end ?? r.start) > monthIndex(a) ? r.end ?? r.start : a), roles[0]?.start ?? '2000-01');
function monthName(s: string, locale: Locale, style: 'short' | 'numeric' = 'short') {
  const { y, m } = ym(s);
  return new Intl.DateTimeFormat(TAG[locale], { month: style === 'numeric' ? '2-digit' : 'short', year: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(y, m - 1, 1)));
}
function span(r: { start: string; end?: string }, t: Texts, locale: Locale) {
  return `${monthName(r.start, locale)} ${t.to} ${r.end ? monthName(r.end, locale) : t.present}`;
}
function duration(r: { start: string; end?: string }, now: string, t: Texts) {
  const n = monthIndex(r.end ?? now) - monthIndex(r.start) + 1;
  const y = Math.floor(n / 12);
  const m = n % 12;
  const parts = [y ? (y === 1 ? t.year : fill(t.years, { n: y })) : '', m ? (m === 1 ? t.month : fill(t.months, { n: m })) : ''].filter(Boolean);
  return parts.join(' ');
}

const MOTION_CSS = `
@keyframes kitcv-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitcv-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitcv-grow{from{transform:scaleX(0)}}
@media (prefers-reduced-motion:reduce){[class*="kitcv-"],.kitcv-anim{animation:none!important;transition:none!important}}`;

function CvMotion() {
  return (
    <style href="kitcv-motion" precedence="default">
      {MOTION_CSS}
    </style>
  );
}

function Live({ text }: { text: string }) {
  return (
    <span className="inline-flex min-h-8 items-center gap-2 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-3 text-[13px] font-medium text-success-foreground">
      <span className="relative flex size-2">
        <span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
        <span className="relative size-2 rounded-full bg-success" />
      </span>
      {text}
    </span>
  );
}

/** Five dots, filled up to the level, they fill one after another when in view */
function Level({ level, label, delay = 0 }: { level: number; label: string; delay?: number }) {
  const [ref, inView] = useInView<HTMLSpanElement>();
  return (
    <span ref={ref} role="img" aria-label={label} className="flex shrink-0 gap-1">
      {[1, 2, 3, 4, 5].map((i) => (
        <span key={i} className={cn('size-2 rounded-full transition-colors duration-500', i <= level && inView ? 'bg-primary' : 'bg-border-strong')} style={{ transitionDelay: inView ? `${delay + i * 70}ms` : '0ms' }} />
      ))}
    </span>
  );
}

function ContactList({ person, t }: { person: ResumePerson; t: Texts }) {
  const rows: { icon: IconName; text: string; href?: string }[] = [
    person.location ? { icon: 'map-pin', text: person.location } : null,
    person.email ? { icon: 'mail', text: person.email, href: `mailto:${person.email}` } : null,
    person.phone ? { icon: 'phone', text: person.phone, href: `tel:${person.phone.replace(/[^+\d]/g, '')}` } : null,
    person.website ? { icon: 'globe', text: person.website.replace(/^https?:\/\//, ''), href: person.website } : null,
  ].filter(Boolean) as { icon: IconName; text: string; href?: string }[];
  return (
    <ul aria-label={t.contact} className="grid gap-1">
      {rows.map((r) => (
        <li key={r.text}>
          {r.href ? (
            <a href={r.href} className={cn('group flex min-h-11 sm:min-h-10 items-center gap-3 rounded-md text-[14px] text-foreground', focusRing)}>
              <Icon name={r.icon} size={16} className="text-muted-foreground transition-colors group-hover:text-primary" />
              <span className="min-w-0 break-words underline-offset-4 group-hover:underline">{r.text}</span>
            </a>
          ) : (
            <span className="flex min-h-10 items-center gap-3 text-[14px] text-foreground">
              <Icon name={r.icon} size={16} className="text-muted-foreground" />
              {r.text}
            </span>
          )}
        </li>
      ))}
    </ul>
  );
}

function Actions({ download, links = [], t, className }: { download?: ResumeProps['download']; links?: ResumeProps['links']; t: Texts; className?: string }) {
  if (!download && !links.length) return null;
  return (
    <div className={cn('flex flex-wrap gap-3', className)}>
      {download && (
        <Magnetic className="max-sm:flex max-sm:w-full">
          <Button href={download.href} iconLeft="download" download className="max-sm:w-full">
            {t.download}
            {download.size && <span className="font-normal opacity-75">{download.size}</span>}
          </Button>
        </Magnetic>
      )}
      {links.map((l) => (
        <Button key={l.label} href={l.href} variant="outline" iconLeft={l.icon} icon={l.icon ? undefined : 'arrow-up-right'} className="max-sm:w-full">
          {l.label}
        </Button>
      ))}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* classic                                                             */
/* ------------------------------------------------------------------ */

function Classic({ person, experience = [], education = [], skills = [], languages = [], download, links, t }: ResumeProps & { t: Texts }) {
  const locale = useLocale();
  const now = useNow(latest(experience));
  const line = useScrollProgress<HTMLOListElement>({ start: 0.1, end: 0.75 });
  const groups = [...new Set(skills.map((s) => s.group))];
  return (
    <div className="grid grid-cols-1 gap-12 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)] lg:grid-rows-[auto_1fr] lg:gap-x-16 lg:gap-y-10">
      <aside className="lg:col-start-1 lg:row-start-1">
        <div className="flex items-center gap-5 lg:block">
          <div className="relative size-24 shrink-0 overflow-hidden rounded-2xl bg-muted sm:size-28 lg:aspect-square lg:size-auto lg:w-full" data-clip-ok>
            <Parallax speed={30} className={cn('absolute -inset-y-4 inset-x-0', fit)}>
              {person.photo}
            </Parallax>
          </div>
          <div className="min-w-0 lg:mt-6">
            <h3 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[28px]">{person.name}</h3>
            <p className="mt-1 text-[15px] text-muted-foreground">{person.role}</p>
          </div>
        </div>
        {person.available && (
          <div className="mt-5">
            <Live text={person.available} />
          </div>
        )}
        <div className="mt-6 border-t border-border pt-5">
          <ContactList person={person} t={t} />
        </div>
        <Actions download={download} t={t} className="mt-6" />
      </aside>
      <aside className="max-lg:order-last lg:col-start-1 lg:row-start-2">
        <div className="grid grid-cols-1 gap-8 border-t border-border pt-8 sm:grid-cols-2 lg:grid-cols-1 lg:border-0 lg:pt-0">
          {groups.length > 0 && (
            <div>
              <h4 className="text-[13px] font-medium text-muted-foreground">{t.skills}</h4>
              <ul className="mt-3 grid gap-2.5">
                {skills.slice(0, 8).map((s, i) => (
                  <li key={s.name} className="flex items-center justify-between gap-4 text-[14px] text-foreground">
                    <span className="min-w-0">{s.name}</span>
                    <Level level={s.level} label={fill(t.level, { n: s.level })} delay={i * 40} />
                  </li>
                ))}
              </ul>
            </div>
          )}
          {languages.length > 0 && (
            <div>
              <h4 className="text-[13px] font-medium text-muted-foreground">{t.languages}</h4>
              <ul className="mt-3 grid gap-2.5">
                {languages.map((l) => (
                  <li key={l.name} className="flex items-baseline justify-between gap-4 text-[14px]">
                    <span className="text-foreground">{l.name}</span>
                    <span className="text-muted-foreground">{l.level}</span>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      </aside>
      <div className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
        {person.summary && <p className="max-w-[42rem] text-pretty text-lead text-foreground">{person.summary}</p>}
        <h4 className={cn('text-[13px] font-medium text-muted-foreground', person.summary && 'mt-12')}>{t.experience}</h4>
        <ol ref={line} className="relative mt-6">
          <span aria-hidden className="absolute bottom-2 left-[5px] top-2 w-px bg-border" />
          <span aria-hidden className="kitcv-anim absolute bottom-2 left-[5px] top-2 w-px origin-top bg-primary" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
          {experience.map((r, i) => (
            <Reveal as="li" key={r.id} delay={i * 60} className="relative pb-10 pl-9 last:pb-0">
              <span aria-hidden className={cn('absolute left-0 top-[7px] size-[11px] rounded-full border-2 bg-background', r.end ? 'border-border-strong' : 'border-primary')}>
                {!r.end && <span className="absolute inset-[2px] rounded-full bg-primary" />}
              </span>
              <div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
                <h5 className="text-[17px] font-semibold tracking-[-0.01em] text-foreground">
                  {r.role} <span className="font-normal text-muted-foreground">· {r.company}</span>
                </h5>
                <p className="text-[13.5px] tabular-nums text-muted-foreground">{span(r, t, locale)}</p>
              </div>
              <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13.5px] text-muted-foreground">
                <span className="tabular-nums">{duration(r, now, t)}</span>
                {r.place && <span aria-hidden>·</span>}
                {r.place && <span>{r.place}</span>}
                {r.kind && <span className="rounded-full border border-border px-2 py-0.5 text-[12px]">{r.kind}</span>}
              </p>
              {r.summary && <p className="mt-3 max-w-[42rem] text-pretty text-[15px] leading-relaxed text-foreground/85">{r.summary}</p>}
              {r.points?.length ? (
                <ul className="mt-3 grid max-w-[42rem] gap-1.5">
                  {r.points.map((p) => (
                    <li key={p} className="flex gap-2.5 text-[14.5px] leading-relaxed text-foreground/85">
                      <span aria-hidden className="mt-[0.62em] size-1 shrink-0 rounded-full bg-primary" />
                      {p}
                    </li>
                  ))}
                </ul>
              ) : null}
            </Reveal>
          ))}
        </ol>
        {education.length > 0 && (
          <>
            <h4 className="mt-14 text-[13px] font-medium text-muted-foreground">{t.education}</h4>
            <Stagger as="ul" step={70} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
              {education.map((e) => (
                <li key={e.id} className="kit-card p-5">
                  <p className="text-[13px] tabular-nums text-muted-foreground">{span(e, t, locale)}</p>
                  <p className="mt-1.5 text-[15.5px] font-semibold text-foreground">{e.title}</p>
                  <p className="mt-0.5 text-[14px] text-muted-foreground">{e.school}</p>
                  {e.note && <p className="mt-2 text-[13.5px] text-foreground/80">{e.note}</p>}
                </li>
              ))}
            </Stagger>
          </>
        )}
        {links?.length ? <Actions links={links} t={t} className="mt-10" /> : null}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* timeline                                                            */
/* ------------------------------------------------------------------ */

function Timeline({ person, experience = [], download, t }: ResumeProps & { t: Texts }) {
  const locale = useLocale();
  const fallback = latest(experience);
  const now = useNow(fallback);
  const [ref, inView] = useInView<HTMLDivElement>();
  const startIdx = Math.min(...experience.map((r) => monthIndex(r.start)));
  const first = Math.floor(startIdx / 12);
  const lastIdx = Math.max(...experience.map((r) => monthIndex(r.end ?? now)));
  const last = Math.floor(lastIdx / 12) + 1;
  const years = Array.from({ length: last - first }, (_, i) => first + i);
  const total = (last - first) * 12;
  const pos = (s: string) => round(((monthIndex(s) - first * 12) / total) * 100, 3);
  const current = experience.find((r) => !r.end) ?? experience[experience.length - 1];
  const [sel, setSel] = useState(current?.id ?? '');
  const chosen = experience.find((r) => r.id === sel) ?? current;
  const base = useId();
  const companies = [...new Set(experience.map((r) => r.company))];
  const key = (e: React.KeyboardEvent) => {
    const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!d) return;
    e.preventDefault();
    const order = [...experience].sort((a, b) => monthIndex(a.start) - monthIndex(b.start));
    const i = order.findIndex((r) => r.id === sel);
    const n = order[(i + d + order.length) % order.length];
    setSel(n.id);
    (e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-id="${n.id}"]`)?.focus();
  };
  return (
    <div>
      <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
        <div>
          <h3 className="text-h3 font-semibold text-foreground">{person.name}</h3>
          <p className="mt-1 text-[15px] text-muted-foreground">{person.role}</p>
        </div>
        <Actions download={download} t={t} />
      </div>
      {/* Desktop and tablet: bars over the years */}
      <div ref={ref} role="listbox" aria-label={t.experience} aria-orientation="horizontal" onKeyDown={key} className="kit-card mt-10 hidden overflow-hidden p-6 md:block lg:p-8">
        <div className="relative grid gap-y-3" style={{ gridTemplateColumns: 'minmax(0,150px) minmax(0,1fr)' }}>
          <span />
          <div className="relative flex h-6 text-[12px] tabular-nums text-muted-foreground">
            {years.map((y, i) => (
              <span key={y} className="absolute top-0 -translate-x-1/2" style={{ left: `${round((i / years.length) * 100, 3)}%` }}>
                {i === 0 ? <span className="inline-block translate-x-1/2">{y}</span> : y}
              </span>
            ))}
          </div>
          {companies.map((c, ci) => (
            <div key={c} className="contents">
              <span className="flex min-h-11 items-center truncate pr-4 text-[14px] font-medium text-foreground">{c}</span>
              <div className="relative min-h-11 rounded-md bg-[repeating-linear-gradient(to_right,var(--border)_0,var(--border)_1px,transparent_1px,transparent_calc(100%/var(--n)))]" style={{ '--n': years.length } as React.CSSProperties}>
                {experience
                  .filter((r) => r.company === c)
                  .map((r) => {
                    const on = r.id === chosen?.id;
                    const left = pos(r.start);
                    const width = Math.max(2.2, round(pos(r.end ?? now) - left + (100 / total), 3));
                    return (
                      <button key={r.id} data-id={r.id} type="button" role="option" aria-selected={on} aria-controls={`${base}-detail`} tabIndex={on ? 0 : -1} onClick={() => setSel(r.id)} title={`${r.role}, ${span(r, t, locale)}`} className={cn('absolute inset-y-1 z-[1] origin-left overflow-hidden rounded-md px-2.5 text-left text-[12.5px] font-medium transition-[background-color,color,box-shadow,transform] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', focusRing, on ? 'bg-primary text-primary-foreground shadow-[var(--shadow-sm)]' : 'bg-[color-mix(in_oklab,var(--primary)_18%,var(--background))] text-foreground hover:bg-[color-mix(in_oklab,var(--primary)_30%,var(--background))]')} style={{ left: `${left}%`, width: `${Math.min(width, round(100 - left, 3))}%`, transform: `scaleX(${inView ? 1 : 0})`, transitionDelay: inView ? `${ci * 110}ms, ${ci * 110}ms, 0ms, ${ci * 110}ms` : '0ms' }}>
                        <span className="block truncate leading-[36px]">{r.role}</span>
                      </button>
                    );
                  })}
              </div>
            </div>
          ))}
        </div>
      </div>
      {chosen && (
        <div id={`${base}-detail`} aria-live="polite" aria-label={t.details} className="mt-6 hidden md:block">
          <div key={chosen.id} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)] gap-10 border-t border-border pt-6 [animation:kitcv-swap_.5s_var(--ease-kit)]">
            <div>
              <p className="text-[13px] tabular-nums text-muted-foreground">
                {span(chosen, t, locale)} · {duration(chosen, now, t)}
              </p>
              <h4 className="mt-2 text-[22px] font-semibold tracking-[-0.02em] text-foreground">{chosen.role}</h4>
              <p className="mt-1 text-[15px] text-muted-foreground">
                {chosen.company}
                {chosen.place && ` · ${chosen.place}`}
              </p>
              {chosen.skills?.length ? (
                <ul className="mt-4 flex flex-wrap gap-1.5">
                  {chosen.skills.map((s) => (
                    <li key={s} className="rounded-full border border-border px-2.5 py-1 text-[12.5px] text-foreground">
                      {s}
                    </li>
                  ))}
                </ul>
              ) : null}
            </div>
            <div>
              {chosen.summary && <p className="text-pretty text-[15.5px] leading-relaxed text-foreground">{chosen.summary}</p>}
              {chosen.points?.length ? (
                <ul className="mt-3 grid gap-1.5">
                  {chosen.points.map((p, i) => (
                    <li key={p} className="flex gap-2.5 text-[14.5px] leading-relaxed text-foreground/85 [animation:kitcv-in_.45s_var(--ease-kit)_both]" style={{ animationDelay: `${120 + i * 60}ms` }}>
                      <Icon name="check" size={16} className="mt-[3px] text-primary" />
                      {p}
                    </li>
                  ))}
                </ul>
              ) : null}
            </div>
          </div>
        </div>
      )}
      {/* Phones: a list, each role with a small bar of its place in the years */}
      <Stagger as="ol" step={70} className="mt-8 grid gap-3 md:hidden">
        {[...experience].reverse().map((r) => {
          const left = pos(r.start);
          const width = Math.max(3, pos(r.end ?? now) - left + 100 / total);
          return (
            <li key={r.id} className="kit-card p-4">
              <div className="relative h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" aria-hidden>
                <span className="absolute inset-y-0 rounded-full bg-primary" style={{ left: `${left}%`, width: `${round(Math.min(width, 100 - left), 3)}%` }} />
              </div>
              <div className="mt-1.5 flex justify-between text-[11.5px] tabular-nums text-muted-foreground">
                <span>{first}</span>
                <span>{last - 1}</span>
              </div>
              <p className="mt-3 text-[16px] font-semibold text-foreground">{r.role}</p>
              <p className="mt-0.5 text-[14px] text-muted-foreground">{r.company}</p>
              <p className="mt-1 text-[13px] tabular-nums text-muted-foreground">
                {span(r, t, locale)} · {duration(r, now, t)}
              </p>
              {r.summary && <p className="mt-2.5 text-pretty text-[14px] leading-relaxed text-foreground/85">{r.summary}</p>}
            </li>
          );
        })}
      </Stagger>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* skills                                                              */
/* ------------------------------------------------------------------ */

function Skills({ person, experience = [], skills = [], t }: ResumeProps & { t: Texts }) {
  const locale = useLocale();
  const groups = [...new Set(skills.map((s) => s.group))];
  const [group, setGroup] = useState<string>('');
  const [pick, setPick] = useState<string>(skills.find((s) => experience.some((r) => r.skills?.includes(s.name)))?.name ?? '');
  const shown = group ? skills.filter((s) => s.group === group) : skills;
  const used = experience.filter((r) => r.skills?.includes(pick));
  const count = (name: string) => experience.filter((r) => r.skills?.includes(name)).length;
  const [row, fade] = useScrollFade<HTMLDivElement>();
  const usedList = (compact: boolean) =>
    used.length ? (
      <ol key={pick} className={cn('divide-y divide-border', compact && 'border-t border-border')}>
        {used.map((r, i) => (
          <li key={r.id} className={cn('[animation:kitcv-in_.45s_var(--ease-kit)_both]', compact ? 'px-4 py-3' : 'px-6 py-4')} style={{ animationDelay: `${80 + i * 70}ms` }}>
            <div className="flex items-baseline justify-between gap-4">
              <p className={cn('font-semibold text-foreground', compact ? 'text-[14px]' : 'text-[15px]')}>{r.role}</p>
              {!r.end && <span className="shrink-0 rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-2 py-0.5 text-[11.5px] font-semibold text-foreground">{t.current}</span>}
            </div>
            <p className="mt-0.5 text-[13px] text-muted-foreground">
              {r.company} · <span className="tabular-nums">{span(r, t, locale)}</span>
            </p>
            {!compact && r.points?.[0] && <p className="mt-2 text-pretty text-[14px] leading-relaxed text-foreground/85">{r.points[0]}</p>}
          </li>
        ))}
      </ol>
    ) : (
      <p className={cn('text-[14px] text-muted-foreground', compact ? 'border-t border-border px-4 py-3' : 'px-6 py-8')}>{t.pick}</p>
    );
  return (
    <div>
      <div ref={row} role="group" aria-label={t.groups} className={cn('kit-no-scrollbar -mx-6 flex gap-2 overflow-x-auto px-6 sm:mx-0 sm:flex-wrap sm:px-0', fade)}>
        {['', ...groups].map((g) => {
          const on = g === group;
          return (
            <button key={g || 'all'} type="button" aria-pressed={on} onClick={() => setGroup(g)} className={cn('inline-flex min-h-11 shrink-0 items-center rounded-full border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-300', focusRing, on ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground')}>
              {g || t.all}
            </button>
          );
        })}
      </div>
      <div className="mt-6 grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] lg:gap-14">
        <ul key={group} className="grid grid-cols-1 gap-px self-start overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2">
          {shown.map((s, i) => {
            const on = s.name === pick;
            const n = count(s.name);
            return (
              <li key={s.name} className={cn('bg-card', on && 'max-lg:sm:col-span-2', shown.length % 2 === 1 && i === shown.length - 1 && 'lg:col-span-2')}>
                <button type="button" aria-pressed={on} onClick={() => setPick(s.name)} className={cn('group relative flex min-h-[68px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors duration-300', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0', on ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))]' : 'hover:bg-accent')}>
                  <span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px] origin-top bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', on ? 'scale-y-100' : 'scale-y-0')} />
                  <span className="min-w-0 [animation:kitcv-in_.4s_var(--ease-kit)_both]" style={{ animationDelay: `${i * 30}ms` }}>
                    <span className="block text-[15px] font-medium text-foreground">{s.name}</span>
                    <span className="mt-0.5 block text-[12.5px] text-muted-foreground">{n === 1 ? t.role1 : fill(t.roles, { n })}</span>
                  </span>
                  <Level level={s.level} label={fill(t.level, { n: s.level })} delay={i * 30} />
                </button>
                {/* Phones and tablets: where it was used opens right under the skill */}
                {on && (
                  <div className="bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] lg:hidden" aria-live="polite">
                    <p className="sr-only">{t.usedIn}</p>
                    {usedList(true)}
                  </div>
                )}
              </li>
            );
          })}
          {(shown.length + (shown.some((x) => x.name === pick) ? 1 : 0)) % 2 === 1 && <li aria-hidden className="hidden bg-card sm:block lg:hidden" />}
        </ul>
        <div className="hidden lg:sticky lg:top-8 lg:block lg:self-start">
          <div className="kit-card overflow-hidden">
            <div className="border-b border-border px-6 py-5">
              <p className="text-[13px] font-medium text-muted-foreground">{t.usedIn}</p>
              <p key={pick} className="mt-1 text-[22px] font-semibold tracking-[-0.02em] text-foreground [animation:kitcv-swap_.45s_var(--ease-kit)]">
                {pick || person.name}
              </p>
            </div>
            <div aria-live="polite">{usedList(false)}</div>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* compact                                                             */
/* ------------------------------------------------------------------ */

function Compact({ person, experience = [], education = [], skills = [], languages = [], download, t }: ResumeProps & { t: Texts }) {
  const locale = useLocale();
  const sheet = useScrollProgress<HTMLDivElement>({ start: 0, end: 0.35 });
  return (
    <div>
      <div className="mb-8 flex flex-wrap items-center justify-center gap-3 max-sm:flex-col max-sm:items-stretch">
        <Actions download={download} t={t} />
        <Button variant="outline" iconLeft="printer" className="max-sm:w-full" onClick={() => window.print()}>
          {t.print}
        </Button>
      </div>
      <div ref={sheet} className="mx-auto max-w-[860px]" style={{ perspective: '1600px' }}>
        <article className="kitcv-anim origin-top overflow-hidden rounded-md bg-card shadow-[var(--highlight),var(--shadow-lg)] ring-1 ring-border" style={{ transform: 'scale(calc(0.94 + var(--kit-progress, 1) * 0.06)) translateY(calc((1 - var(--kit-progress, 1)) * 24px))' }}>
          <div className="grid grid-cols-1 gap-6 border-b border-border p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:p-10">
            <div className="size-20 overflow-hidden rounded-full bg-muted sm:size-24" data-clip-ok>
              <div className={cn('size-full', fit)}>{person.photo}</div>
            </div>
            <div>
              <h3 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[30px]">{person.name}</h3>
              <p className="mt-1 text-[15px] text-primary">{person.role}</p>
              <p className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[13.5px] text-muted-foreground max-sm:flex-col">
                {[person.location, person.email, person.phone, person.website?.replace(/^https?:\/\//, '')].filter(Boolean).map((x, i) => (
                  <span key={x} className="inline-flex items-center gap-3">
                    {i > 0 && <span aria-hidden className="size-1 rounded-full bg-border-strong max-sm:hidden" />}
                    {x}
                  </span>
                ))}
              </p>
            </div>
          </div>
          <div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,220px)]">
            <div className="p-6 sm:p-10">
              {person.summary && <p className="text-pretty text-[14.5px] leading-relaxed text-foreground/90">{person.summary}</p>}
              <h4 className="mt-8 border-b border-border pb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.experience}</h4>
              <ol className="mt-4 grid gap-5">
                {experience.map((r) => (
                  <li key={r.id} className="grid grid-cols-1 gap-x-6 sm:grid-cols-[110px_minmax(0,1fr)]">
                    <p className="text-[12.5px] tabular-nums text-muted-foreground">
                      {monthName(r.start, locale, 'numeric')}
                      <br className="max-sm:hidden" />
                      <span className="sm:hidden"> </span>
                      {t.to} {r.end ? monthName(r.end, locale, 'numeric') : t.present}
                    </p>
                    <div>
                      <p className="text-[14.5px] font-semibold text-foreground">
                        {r.role}, <span className="font-normal">{r.company}</span>
                      </p>
                      {r.points?.length ? (
                        <ul className="mt-1.5 grid gap-1">
                          {r.points.slice(0, 2).map((p) => (
                            <li key={p} className="flex gap-2 text-[13.5px] leading-relaxed text-foreground/80">
                              <span aria-hidden className="mt-[0.6em] size-1 shrink-0 rounded-full bg-muted-foreground" />
                              {p}
                            </li>
                          ))}
                        </ul>
                      ) : null}
                    </div>
                  </li>
                ))}
              </ol>
              {education.length > 0 && (
                <>
                  <h4 className="mt-8 border-b border-border pb-2 text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.education}</h4>
                  <ul className="mt-4 grid gap-3">
                    {education.map((e) => (
                      <li key={e.id} className="grid grid-cols-1 gap-x-6 sm:grid-cols-[110px_minmax(0,1fr)]">
                        <p className="text-[12.5px] tabular-nums text-muted-foreground">
                          {ym(e.start).y} {t.to} {e.end ? ym(e.end).y : t.present}
                        </p>
                        <p className="text-[14px] text-foreground">
                          <span className="font-semibold">{e.title}</span>, {e.school}
                        </p>
                      </li>
                    ))}
                  </ul>
                </>
              )}
            </div>
            <div className="border-t border-border bg-surface p-6 sm:border-l sm:border-t-0 sm:p-8">
              <h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.skills}</h4>
              <ul className="mt-3 flex flex-wrap gap-1.5 sm:block sm:space-y-2">
                {skills.map((s) => (
                  <li key={s.name} className="rounded-full border border-border px-2.5 py-1 text-[13px] text-foreground sm:rounded-none sm:border-0 sm:p-0">
                    {s.name}
                  </li>
                ))}
              </ul>
              {languages.length > 0 && (
                <>
                  <h4 className="mt-8 text-[12px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t.languages}</h4>
                  <ul className="mt-3 grid gap-2">
                    {languages.map((l) => (
                      <li key={l.name} className="text-[13px]">
                        <span className="block text-foreground">{l.name}</span>
                        <span className="block text-muted-foreground">{l.level}</span>
                      </li>
                    ))}
                  </ul>
                </>
              )}
            </div>
          </div>
        </article>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* summary                                                             */
/* ------------------------------------------------------------------ */

function Summary({ person, experience = [], stats = [], download, links, t }: ResumeProps & { t: Texts }) {
  const locale = useLocale();
  const now = useNow(latest(experience));
  const current = experience.find((r) => !r.end);
  const past = experience.filter((r) => r.end).slice(0, 4);
  const lineRef = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.6 });
  return (
    <div className="grid grid-cols-1 gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:items-center lg:gap-16">
      <div>
        <div className="kit-rise flex items-center gap-4">
          <div className="size-16 overflow-hidden rounded-full bg-muted ring-2 ring-background" data-clip-ok>
            <div className={cn('size-full', fit)}>{person.photo}</div>
          </div>
          <div>
            <p className="text-[16px] font-semibold text-foreground">{person.name}</p>
            {person.location && <p className="text-[14px] text-muted-foreground">{person.location}</p>}
          </div>
        </div>
        <h3 className="kit-rise mt-8 text-balance text-h1 font-semibold text-foreground [--kit-delay:80ms]">{person.role}</h3>
        {person.summary && <p className="kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground [--kit-delay:160ms]">{person.summary}</p>}
        {person.available && (
          <div className="kit-rise mt-6 [--kit-delay:220ms]">
            <Live text={person.available} />
          </div>
        )}
        <Actions download={download} links={links} t={t} className="kit-rise mt-8 [--kit-delay:280ms]" />
        {stats.length > 0 && (
          <dl className="mt-12 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-3">
            {stats.slice(0, 3).map((s, i) => (
              <div key={s.label} className={cn('bg-card p-5', i === 2 && 'max-sm:col-span-2')}>
                <dt className="sr-only">{s.label}</dt>
                <dd>
                  <span className="text-[32px] font-semibold leading-none tracking-[-0.03em] text-foreground">
                    <NumberTicker value={s.value} delay={i * 120} />
                    {s.suffix}
                  </span>
                  <span aria-hidden className="mt-2 block text-[13.5px] leading-snug text-muted-foreground">
                    {s.label}
                  </span>
                </dd>
              </div>
            ))}
          </dl>
        )}
      </div>
      <div ref={lineRef} className="kit-card relative overflow-hidden p-6 sm:p-8">
        {current && (
          <div className="relative">
            <p className="inline-flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
              <span className="relative flex size-2">
                <span aria-hidden className="absolute inset-0 animate-ping rounded-full bg-primary opacity-60 motion-reduce:animate-none" />
                <span className="relative size-2 rounded-full bg-primary" />
              </span>
              {t.nowAt}
            </p>
            <p className="mt-2 text-[20px] font-semibold tracking-[-0.01em] text-foreground">{current.role}</p>
            <p className="mt-0.5 text-[15px] text-muted-foreground">
              {current.company} · <span className="tabular-nums">{fill(t.since, { d: monthName(current.start, locale) })}</span>
            </p>
            {current.summary && <p className="mt-3 text-pretty text-[14.5px] leading-relaxed text-foreground/85">{current.summary}</p>}
          </div>
        )}
        {past.length > 0 && (
          <ol className="relative mt-7 border-t border-border pt-6">
            <span aria-hidden className="absolute bottom-3 left-[3px] top-9 w-px bg-border" />
            <span aria-hidden className="kitcv-anim absolute bottom-3 left-[3px] top-9 w-px origin-top bg-primary" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
            {past.map((r, i) => (
              <Reveal as="li" key={r.id} delay={i * 80} className="relative grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 pb-5 pl-6 last:pb-0">
                <span aria-hidden className="absolute left-0 top-[7px] size-[7px] rounded-full bg-border-strong ring-4 ring-card" />
                <span className="min-w-0">
                  <span className="block text-[14.5px] font-medium text-foreground">{r.role}</span>
                  <span className="block text-[13px] text-muted-foreground">{r.company}</span>
                </span>
                <span className="text-right text-[13px] tabular-nums text-muted-foreground">{duration(r, now, t)}</span>
              </Reveal>
            ))}
          </ol>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Resume                                                              */
/* ------------------------------------------------------------------ */

/**
 * A résumé as a web page.
 *   classic   side column with contact, skills and languages, experience on a filling line
 *   timeline  roles as bars over the years, click one to read it
 *   skills    skills by group with levels, pick one to see where it was used
 *   compact   the one-page paper version with download and print
 *   summary   the short version for the top of a page, with counting numbers
 */
export function Resume(props: ResumeProps) {
  const { variant = 'classic', labels, className, eyebrow, title, description } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'timeline':
        return <Timeline {...props} t={t} />;
      case 'skills':
        return <Skills {...props} t={t} />;
      case 'compact':
        return <Compact {...props} t={t} />;
      case 'summary':
        return <Summary {...props} t={t} />;
      default:
        return <Classic {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'compact' ? 'surface' : variant === 'summary' ? 'glow' : 'default'}>
      <CvMotion />
      {title ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
      {body}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Lebenslauf?

Mit aiw add @ai-website/resume. Der Befehl legt die Datei components/ui/resume.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, number-ticker, reveal und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Lebenslauf?

5 Varianten, Klassisch, Zeitleiste, Fähigkeiten, Kompakt und Kurzfassung. Du wählst sie mit der Prop variant, zum Beispiel variant="timeline".

Kann ich die Texte von Lebenslauf übersetzen?

Ja. Lebenslauf bringt 25 eigene Texte auf Deutsch und Englisch mit, etwa „Berufserfahrung“, „Ausbildung“ und „Fähigkeiten“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Lebenslauf einbauen?

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

Kann ich Lebenslauf ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert