Skip to content
UI Kit
Accent color

Résumé

A CV as a web page. Experience on a line that fills, roles as bars over the years, skills that show where they were used, the one-page paper version and a short summary with counting numbers.

One of 3 components in Personal brand

5 variants

Variants

Classic

A side column with photo, contact, download, skills and languages that stays in place. Experience on a line that fills while you scroll, education as cards.

Open in Studio

Timeline

Every role as a bar over the years, one row per company, overlaps stay visible. Bars grow in, a click or the arrow keys open the role below. A list with small bars on phones.

Open in Studio

Skills

Skills by group with their level as dots that fill. Pick one and the card beside it shows every role where it was used, so a level is backed by work.

Open in Studio

Compact

The one-page paper version, as it prints. The sheet settles in while it scrolls into view, download and print sit above it.

Open in Studio

Summary

The short version for the top of a personal site. Role as the headline, availability, numbers that count up, the current role and earlier ones on a line that fills.

Open in Studio

Details

What Résumé brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/resume.tsx
Brings along
utilskitbuttoniconmotionnumber-tickerrevealsection
npm packages
None
Languages
English and German, 25 texts of its own

Props

The ResumeProps interface from components/ui/resume.tsx, with the comments from the code.

  • variantoptional
    ResumeVariant

    classictimelineskillscompactsummary

  • eyebrowoptional
    React.ReactNode

    Optional heading above the variant

  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • personrequired
    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

Built-in texts

Résumé writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 25 texts
experienceEnglish ExperienceGerman Berufserfahrung
educationEnglish EducationGerman Ausbildung
skillsEnglish SkillsGerman Fähigkeiten
languagesEnglish LanguagesGerman Sprachen
contactEnglish ContactGerman Kontakt
presentEnglish todayGerman heute
toEnglish toGerman bis
yearsEnglish {n} yrsGerman {n} J.
yearEnglish 1 yrGerman 1 J.
monthsEnglish {n} mosGerman {n} Mon.
monthEnglish 1 moGerman 1 Mon.
downloadEnglish Download PDFGerman PDF herunterladen
printEnglish PrintGerman Drucken
currentEnglish NowGerman Aktuell
usedInEnglish Used inGerman Eingesetzt bei
pickEnglish Pick a skill to see where it was usedGerman Wähle eine Fähigkeit, um zu sehen, wo sie zum Einsatz kam
rolesEnglish {n} rolesGerman {n} Stationen
role1English 1 roleGerman 1 Station
levelEnglish Level {n} of 5German Stufe {n} von 5
allEnglish AllGerman Alle
groupsEnglish Skill groupsGerman Gruppen
detailsEnglish Details of the selected roleGerman Details zur gewählten Station
sinceEnglish since {d}German seit {d}
nowAtEnglish CurrentlyGerman Derzeit
totalEnglish In totalGerman Insgesamt

Accessibility

Read from the source of the component.

  • Résumé handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-controls, aria-label, aria-live, aria-orientation, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles group, img, listbox and option.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

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

Show the file, 863 lines
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>
  );
}

Frequent questions

How do I install Résumé?

With aiw add @ai-website/resume. The command puts components/ui/resume.tsx into your project, the code is yours from then on. It brings along button, icon, motion, number-ticker, reveal and section. It needs no further npm packages.

Which variants does Résumé have?

5 variants, Classic, Timeline, Skills, Compact and Summary. You pick one with the variant prop, for example variant="timeline".

Can I translate the texts of Résumé?

Yes. Résumé carries 25 texts of its own in English and German, such as “Experience”, “Education” and “Skills”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Résumé?

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

Can I adjust Résumé without code?

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

Often searched

Jump to

Copied to the clipboard