Zum Inhalt springen
UI Kit
Accent

Autorenseite

Die Menschen, die schreiben. Ein Autorenprofil mit Artikeln, das Verzeichnis aller Autorinnen, das Impressum einer Redaktion, eine Box mit Autor, Prüferin und Prüfungen und eine Zeile für mehrere Autoren.

Eine von 2 Components in Autoren und Interviews

5 Variants

Variants

Profil

Porträt, Bio, Themen und Zahlen, die hochzählen. Die Artikel filtern sich nach Thema mit gleitendem Umschalter.

Im Studio öffnen

Autorinnen und Autoren

Alle, die schreiben, als Karten mit dem neuesten Text. Ein Ressortfilter blendet die anderen ab, statt das Raster zu verschieben.

Im Studio öffnen

Impressum der Redaktion

Das Team gesetzt wie das Impressum eines Magazins, nach Rolle. Ein Name mit Foto zeigt es beim Darüberfahren oder Fokus.

Im Studio öffnen

Expertenbox

Geschrieben von und geprüft von mit Nachweisen, die Prüfungen in Zahlen und die Arbeitsweise hinter einer weich aufklappenden Zeile.

Im Studio öffnen

Autorenzeile

Mehrere Autoren in einer Zeile unter dem Titel. Ein Gesicht oder Name öffnet eine Karte mit Bio, Folgen und Profil.

Im Studio öffnen

Details

Was Autorenseite mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/author-page.tsx
Bringt mit
utilskitbuttoniconnumber-tickerrevealmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 15 eigene Texte
In Sammlungen
Blog oder Magazin

Props

Die Schnittstelle AuthorPageProps aus components/ui/author-page.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AuthorPageVariant

    profilecontributorsmastheadexpert-boxbyline

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • authoroptional
    Author

    profile: the author; expert-box: the writer

  • revieweroptional
    Author

    expert-box: the reviewer

  • authorsoptional
    Author[]

    contributors, byline

  • articlesoptional
    AuthorArticle[]

    profile

  • statsoptional
    { value: number; label: string; suffix?: string }[]

    profile: figures, e.g. [{ value: 214, label: 'articles' }]

  • groupsoptional
    MastheadGroup[]

    masthead

  • footnoteoptional
    string

    masthead: address or legal line under the team

  • checksoptional
    { label: string; value: string }[]

    expert-box: checks done, e.g. sources, last review

  • methodoptional
    string

    expert-box: how the editorial team works

  • dateoptional
    string

    byline: date line

Eingebaute Texte

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

Alle 15 Texte anzeigen
allEnglisch AllDeutsch Alle
articlesEnglisch ArticlesDeutsch Artikel
writesAboutEnglisch Writes aboutDeutsch Schreibt über
minutesEnglisch {n} minDeutsch {n} Min.
latestEnglisch LatestDeutsch Zuletzt
articlesNEnglisch {n} articlesDeutsch {n} Artikel
beatEnglisch BeatDeutsch Ressort
showingEnglisch {n} articlesDeutsch {n} Artikel
writtenEnglisch Written byDeutsch Geschrieben von
reviewedEnglisch Reviewed byDeutsch Geprüft von
howEnglisch How we checked this textDeutsch So haben wir diesen Text geprüft
followEnglisch FollowDeutsch Folgen
followingEnglisch FollowingDeutsch Du folgst
andEnglisch andDeutsch und
profileEnglisch View profileDeutsch Profil ansehen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Autorenseite reagiert selbst auf die Tasten Escape, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-controls, aria-expanded, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen dialog, radio und radiogroup.
  • 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).
  • 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, 577 Zeilen
components/ui/author-page.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Parallax, Stagger, useScrollFade } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';

/*
 * The people who write. An author page with their work, the directory of
 * all contributors, the masthead of an editorial team, the box that says
 * who wrote and who checked a text, and a byline for several authors.
 *
 *   profile       portrait, bio, beats, figures and the articles, filtered by topic
 *   contributors  everyone who writes, filtered by beat, the latest piece shows on hover
 *   masthead      the editorial team set like the masthead of a magazine, by role
 *   expert-box    written by, reviewed by, credentials and how the text was checked
 *   byline        several authors in one line, a card per person, follow with one press
 */

export type AuthorPageVariant = 'profile' | 'contributors' | 'masthead' | 'expert-box' | 'byline';

export interface AuthorArticle {
  title: string;
  /** Topic, used by the filter */
  topic?: string;
  date?: string;
  readingTime?: number;
  href?: string;
}

export interface Author {
  name: string;
  /** Role or beat, e.g. city editor */
  role?: string;
  bio?: string;
  avatar?: React.ReactNode;
  /** Topics the author covers */
  topics?: string[];
  /** contributors: number of articles */
  count?: number;
  /** contributors: the latest article */
  latest?: string;
  /** expert-box: degrees, memberships, years of practice */
  credentials?: string[];
  href?: string;
  links?: { icon: IconName; label: string; href: string }[];
}

export interface MastheadGroup {
  role: string;
  people: { name: string; note?: string; avatar?: React.ReactNode }[];
}

export interface AuthorPageProps {
  variant?: AuthorPageVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** profile: the author; expert-box: the writer */
  author?: Author;
  /** expert-box: the reviewer */
  reviewer?: Author;
  /** contributors, byline */
  authors?: Author[];
  /** profile */
  articles?: AuthorArticle[];
  /** profile: figures, e.g. [{ value: 214, label: 'articles' }] */
  stats?: { value: number; label: string; suffix?: string }[];
  /** masthead */
  groups?: MastheadGroup[];
  /** masthead: address or legal line under the team */
  footnote?: string;
  /** expert-box: checks done, e.g. sources, last review */
  checks?: { label: string; value: string }[];
  /** expert-box: how the editorial team works */
  method?: string;
  /** byline: date line */
  date?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    all: 'All',
    articles: 'Articles',
    writesAbout: 'Writes about',
    minutes: '{n} min',
    latest: 'Latest',
    articlesN: '{n} articles',
    beat: 'Beat',
    showing: '{n} articles',
    written: 'Written by',
    reviewed: 'Reviewed by',
    how: 'How we checked this text',
    follow: 'Follow',
    following: 'Following',
    and: 'and',
    profile: 'View profile',
  },
  de: {
    all: 'Alle',
    articles: 'Artikel',
    writesAbout: 'Schreibt über',
    minutes: '{n} Min.',
    latest: 'Zuletzt',
    articlesN: '{n} Artikel',
    beat: 'Ressort',
    showing: '{n} Artikel',
    written: 'Geschrieben von',
    reviewed: 'Geprüft von',
    how: 'So haben wir diesen Text geprüft',
    follow: 'Folgen',
    following: 'Du folgst',
    and: 'und',
    profile: 'Profil ansehen',
  },
};

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

/* ------------------------------------------------------------------ */
/* Shared pieces                                                       */
/* ------------------------------------------------------------------ */

function Face({ a, className }: { a: { name: string; avatar?: React.ReactNode }; className?: string }) {
  return (
    <span data-clip-ok className={cn('relative block shrink-0 overflow-hidden rounded-full border border-border bg-muted [&>*]:size-full', className)}>
      {a.avatar ?? (
        <span className="grid size-full place-items-center text-[0.4em] font-semibold text-muted-foreground" aria-hidden>
          {a.name
            .split(' ')
            .map((p) => p[0])
            .slice(0, 2)
            .join('')}
        </span>
      )}
    </span>
  );
}

/** A segmented filter with a gliding pill, arrow keys move */
function Segments({ items, value, onChange, label }: { items: string[]; value: number; onChange: (i: number) => void; label: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
  useLayoutEffect(() => {
    const b = ref.current?.querySelector<HTMLElement>('[aria-checked="true"]');
    if (b) setPill({ x: b.offsetLeft, w: b.offsetWidth });
  }, [value, items.length]);
  const key = (e: React.KeyboardEvent) => {
    if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
    e.preventDefault();
    const n = (value + (e.key === 'ArrowRight' ? 1 : items.length - 1)) % items.length;
    onChange(n);
    ref.current?.querySelectorAll<HTMLElement>('[role="radio"]')[n]?.focus();
  };
  return (
    <div ref={rowRef} className={cn('kit-no-scrollbar -m-1 overflow-x-auto p-1', fade)}>
      <div ref={ref} role="radiogroup" aria-label={label} onKeyDown={key} className="relative inline-flex w-max rounded-xl border border-border bg-surface p-1">
        {pill && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: pill.w, transform: `translateX(${pill.x}px)` }} />}
        {items.map((it, i) => (
          <button key={it} type="button" role="radio" aria-checked={value === i} tabIndex={value === i ? 0 : -1} onClick={() => onChange(i)} className={cn('relative min-h-10 min-w-11 whitespace-nowrap rounded-lg px-3.5 text-[14px] font-medium transition-colors duration-300 max-sm:min-h-11', value === i ? cn('text-foreground', !pill && 'bg-card') : 'text-muted-foreground hover:text-foreground')}>
            {it}
          </button>
        ))}
      </div>
    </div>
  );
}

function Links({ links }: { links?: Author['links'] }) {
  if (!links?.length) return null;
  return (
    <div className="flex flex-wrap gap-2">
      {links.map((l, i) => (
        <a key={i} href={l.href} className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-border px-3 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent max-sm:min-h-11">
          <Icon name={l.icon} size={15} className="text-muted-foreground" />
          {l.label}
        </a>
      ))}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* profile                                                             */
/* ------------------------------------------------------------------ */

function Profile(p: AuthorPageProps & { t: Texts }) {
  const { t } = p;
  const a = p.author;
  const articles = p.articles ?? [];
  const topics = useMemo(() => [t.all, ...Array.from(new Set(articles.map((x) => x.topic).filter(Boolean) as string[]))], [articles, t.all]);
  const [ti, setTi] = useState(0);
  const shown = ti === 0 ? articles : articles.filter((x) => x.topic === topics[ti]);
  if (!a) return null;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="grid grid-cols-[minmax(0,1fr)] gap-10 md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] md:gap-12 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-20">
          <div className="md:sticky md:top-10 md:self-start">
            <Reveal effect="scale" className="w-32 sm:w-40 md:w-full">
              <div data-clip-ok className="aspect-[4/5] overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-md)]">
                <Parallax speed={24} className="size-full scale-[1.08] [&>*]:size-full">
                  {a.avatar}
                </Parallax>
              </div>
            </Reveal>
            {p.stats && p.stats.length > 0 && (
              <dl className="mt-6 hidden grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border md:grid">
                {p.stats.map((s, i) => (
                  <div key={i} className="bg-background px-4 py-3.5">
                    <dt className="text-[12.5px] text-muted-foreground">{s.label}</dt>
                    <dd className="mt-0.5 text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">
                      <NumberTicker value={s.value} />
                      {s.suffix}
                    </dd>
                  </div>
                ))}
              </dl>
            )}
          </div>
          <div className="min-w-0">
            {p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
            <h1 className="kit-rise text-balance text-h1 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
              {a.name}
            </h1>
            {a.role && <p className="kit-rise mt-3 text-[17px] font-medium text-muted-foreground" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>{a.role}</p>}
            {a.bio && <p className="kit-rise mt-6 max-w-[38rem] text-pretty text-[18px] leading-[1.65] text-[color-mix(in_oklab,var(--foreground)_88%,var(--background))]" style={{ '--kit-delay': '150ms' } as React.CSSProperties}>{a.bio}</p>}
            {a.topics && a.topics.length > 0 && (
              <p className="kit-rise mt-5 text-[14.5px] text-muted-foreground" style={{ '--kit-delay': '190ms' } as React.CSSProperties}>
                {t.writesAbout} <span className="text-foreground">{a.topics.join(', ')}</span>
              </p>
            )}
            <div className="kit-rise mt-6" style={{ '--kit-delay': '230ms' } as React.CSSProperties}>
              <Links links={a.links} />
            </div>
            {p.stats && p.stats.length > 0 && (
              <dl className="mt-8 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border md:hidden">
                {p.stats.map((s, i) => (
                  <div key={i} className="bg-background px-4 py-3.5">
                    <dt className="text-[12.5px] text-muted-foreground">{s.label}</dt>
                    <dd className="mt-0.5 text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">
                      <NumberTicker value={s.value} />
                      {s.suffix}
                    </dd>
                  </div>
                ))}
              </dl>
            )}

            <div className="mt-14 flex flex-col gap-4 border-t border-border pt-8 lg:flex-row lg:items-center lg:justify-between">
              <h2 className="text-[22px] font-semibold tracking-[-0.012em] text-foreground">
                {t.articles} <span className="ml-1 text-[15px] font-normal tabular-nums text-muted-foreground" aria-hidden>{shown.length}</span>
                <span className="sr-only" aria-live="polite">{t.showing.replace('{n}', String(shown.length))}</span>
              </h2>
              {topics.length > 2 && <Segments items={topics} value={ti} onChange={setTi} label={t.writesAbout} />}
            </div>
            <ul key={ti} className="mt-4">
              {shown.map((x, i) => (
                <li key={x.title} className="kit-rise border-b border-border" style={{ '--kit-delay': `${i * 50}ms` } as React.CSSProperties}>
                  <a href={x.href ?? '#'} className="group/ar grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-6 gap-y-1 py-5">
                    <span className="text-pretty text-[17px] font-semibold leading-[1.4] text-foreground underline decoration-transparent underline-offset-4 transition-colors duration-300 group-hover/ar:decoration-current sm:text-[18px]">{x.title}</span>
                    <Icon name="arrow-right" size={16} className="-translate-x-1 text-muted-foreground opacity-0 transition-[opacity,transform] duration-300 group-hover/ar:translate-x-0 group-hover/ar:opacity-100 max-sm:hidden" />
                    <span className="col-span-2 text-[13.5px] text-muted-foreground">
                      {[x.topic, x.date, x.readingTime ? t.minutes.replace('{n}', String(x.readingTime)) : null].filter(Boolean).join(' · ')}
                    </span>
                  </a>
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* contributors                                                        */
/* ------------------------------------------------------------------ */

function Contributors(p: AuthorPageProps & { t: Texts }) {
  const { t } = p;
  const authors = p.authors ?? [];
  const beats = useMemo(() => [t.all, ...Array.from(new Set(authors.map((a) => a.role).filter(Boolean) as string[]))], [authors, t.all]);
  const [bi, setBi] = useState(0);
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-[40rem]">
            {p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
            {p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
            {p.description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
          </div>
          {beats.length > 2 && <Segments items={beats} value={bi} onChange={setBi} label={t.beat} />}
        </div>
        <ul className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {authors.map((a, i) => {
            const on = bi === 0 || a.role === beats[bi];
            return (
              <li key={a.name} className={cn('transition-[opacity,filter,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'opacity-100' : 'pointer-events-none scale-[0.98] opacity-30 grayscale')} aria-hidden={!on || undefined}>
                <Reveal effect="rise" delay={(i % 3) * 70} className="h-full">
                  <a href={a.href ?? '#'} tabIndex={on ? undefined : -1} className="group/au relative flex h-full flex-col rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-6">
                    <div className="flex items-center gap-4">
                      <Face a={a} className="size-14 transition-transform duration-500 ease-[var(--ease-kit)] group-hover/au:scale-105" />
                      <div className="min-w-0">
                        <p className="truncate text-[17px] font-semibold text-foreground">{a.name}</p>
                        <p className="mt-0.5 text-[13.5px] text-muted-foreground">
                          {a.role}
                          {a.count !== undefined && <span className="tabular-nums"> · {t.articlesN.replace('{n}', String(a.count))}</span>}
                        </p>
                      </div>
                    </div>
                    {a.bio && <p className="mt-4 line-clamp-3 text-pretty text-[14.5px] leading-[1.6] text-muted-foreground">{a.bio}</p>}
                    {a.latest && (
                      <div className="mt-auto pt-5">
                        <div className="border-t border-border pt-4">
                          <p className="text-[12px] font-medium uppercase tracking-[0.07em] text-muted-foreground">{t.latest}</p>
                          <p className="mt-1.5 line-clamp-2 min-h-[2.9em] text-pretty text-[14.5px] font-medium leading-[1.45] text-foreground transition-colors duration-300 group-hover/au:text-primary">{a.latest}</p>
                        </div>
                      </div>
                    )}
                  </a>
                </Reveal>
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* masthead                                                            */
/* ------------------------------------------------------------------ */

function Masthead(p: AuthorPageProps & { t: Texts }) {
  const groups = p.groups ?? [];
  const [lit, setLit] = useState<string | null>(null);
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <div className="mx-auto max-w-[60rem]">
          <div className="text-center">
            {p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
            {p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
            {p.description && <p className="kit-rise mx-auto mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
          </div>
          <Stagger step={60} className="mt-12 grid grid-cols-[minmax(0,1fr)] border-t border-border sm:mt-14 sm:grid-cols-2 lg:grid-cols-3">
            {groups.map((g, i) => (
              <div key={i} className="border-b border-border py-7 sm:pr-10">
                <h3 className="text-[12.5px] font-semibold uppercase tracking-[0.08em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{g.role}</h3>
                <ul className="mt-3 space-y-1">
                  {g.people.map((m) => {
                    const key = `${i}-${m.name}`;
                    return (
                      <li key={m.name} className="relative">
                        <span
                          tabIndex={m.avatar ? 0 : undefined}
                          onPointerEnter={(e) => e.pointerType === 'mouse' && m.avatar && setLit(key)}
                          onPointerLeave={() => setLit(null)}
                          onFocus={() => m.avatar && setLit(key)}
                          onBlur={() => setLit(null)}
                          className={cn('inline-flex min-h-8 items-baseline gap-2 rounded text-[18px] leading-[1.5] text-foreground transition-colors duration-300', m.avatar && 'cursor-default hover:text-primary focus-visible:text-primary')}
                        >
                          {m.name}
                          {m.note && <span className="text-[13.5px] text-muted-foreground">{m.note}</span>}
                        </span>
                        {m.avatar && lit === key && (
                          <span className="kit-rise pointer-events-none absolute bottom-[calc(100%+6px)] left-0 z-20 flex items-center gap-3 rounded-xl border border-border bg-popover p-2 pr-4 shadow-[var(--highlight),var(--shadow-lg)]" style={{ animationDuration: '.35s' }} aria-hidden>
                            <Face a={m} className="size-11" />
                            <span className="text-[13.5px] font-medium text-popover-foreground">{m.name}</span>
                          </span>
                        )}
                      </li>
                    );
                  })}
                </ul>
              </div>
            ))}
          </Stagger>
          {p.footnote && <Reveal effect="fade"><p className="mt-8 text-center text-[13.5px] leading-[1.6] text-muted-foreground">{p.footnote}</p></Reveal>}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* expert-box                                                          */
/* ------------------------------------------------------------------ */

function ExpertBox(p: AuthorPageProps & { t: Texts }) {
  const { t } = p;
  const uid = useId().replace(/:/g, '');
  const [open, setOpen] = useState(false);
  const people = [
    p.author && { a: p.author, label: t.written },
    p.reviewer && { a: p.reviewer, label: t.reviewed },
  ].filter(Boolean) as { a: Author; label: string }[];
  return (
    <section className={cn('py-16 sm:py-20', p.className)}>
      <div className="kit-container">
        <Reveal effect="rise" className="mx-auto max-w-[46rem]">
          <div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
            {p.title && (
              <div className="flex items-center gap-2.5 border-b border-border px-5 py-4 sm:px-6">
                <Icon name="shield" size={17} className="text-primary" />
                <h2 className="text-[15px] font-semibold text-foreground">{p.title}</h2>
              </div>
            )}
            <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px bg-border', people.length > 1 && 'sm:grid-cols-2')}>
              {people.map(({ a, label }, i) => (
                <div key={i} className="bg-card p-5 sm:p-6">
                  <p className="text-[12.5px] font-medium text-muted-foreground">{label}</p>
                  <div className="mt-3 flex items-center gap-3.5">
                    <Face a={a} className="size-12" />
                    <div className="min-w-0">
                      <a href={a.href ?? '#'} className="text-[16px] font-semibold text-foreground underline decoration-transparent underline-offset-4 transition-colors hover:decoration-current">
                        {a.name}
                      </a>
                      {a.role && <p className="text-[13.5px] text-muted-foreground">{a.role}</p>}
                    </div>
                  </div>
                  {a.credentials && a.credentials.length > 0 && (
                    <ul className="mt-4 space-y-2">
                      {a.credentials.map((c, j) => (
                        <li key={j} className="flex gap-2.5 text-[14px] leading-[1.5] text-foreground">
                          <Icon name="check" size={15} className="mt-[3px] text-success" />
                          {c}
                        </li>
                      ))}
                    </ul>
                  )}
                </div>
              ))}
            </div>
            {p.checks && p.checks.length > 0 && (
              <dl className="grid grid-cols-2 gap-px border-t border-border bg-border sm:grid-cols-4">
                {p.checks.map((c, i) => (
                  <div key={i} className="bg-surface px-5 py-4 sm:px-6">
                    <dt className="text-[12px] text-muted-foreground">{c.label}</dt>
                    <dd className="mt-1 text-[15px] font-semibold tabular-nums text-foreground">{c.value}</dd>
                  </div>
                ))}
              </dl>
            )}
            {p.method && (
              <div className="border-t border-border">
                <button type="button" aria-expanded={open} aria-controls={`${uid}-m`} onClick={() => setOpen((o) => !o)} className="flex min-h-12 w-full items-center justify-between gap-3 px-5 text-left focus-visible:outline-offset-[-2px] text-[14.5px] font-medium text-foreground transition-colors hover:bg-accent sm:px-6">
                  {t.how}
                  <Icon name="plus" size={16} className={cn('text-muted-foreground transition-transform duration-300', open && 'rotate-45')} />
                </button>
                <div id={`${uid}-m`} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                  <div className="min-h-0 overflow-hidden" inert={!open}>
                    <p className="px-5 pb-5 text-pretty text-[14.5px] leading-[1.65] text-muted-foreground sm:px-6">{p.method}</p>
                  </div>
                </div>
              </div>
            )}
          </div>
        </Reveal>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* byline                                                              */
/* ------------------------------------------------------------------ */

function Byline(p: AuthorPageProps & { t: Texts }) {
  const { t } = p;
  const authors = p.authors ?? [];
  const [card, setCard] = useState<number | null>(null);
  const [follow, setFollow] = useState<Set<number>>(new Set());
  const box = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (card === null) return;
    const off = (e: PointerEvent) => {
      if (!box.current?.contains(e.target as Node)) setCard(null);
    };
    const esc = (e: KeyboardEvent) => e.key === 'Escape' && setCard(null);
    document.addEventListener('pointerdown', off);
    document.addEventListener('keydown', esc);
    return () => {
      document.removeEventListener('pointerdown', off);
      document.removeEventListener('keydown', esc);
    };
  }, [card]);
  const a = card !== null ? authors[card] : null;
  return (
    <section className={cn('py-16 sm:py-20', p.className)}>
      <div className="kit-container">
        <div ref={box} className="relative mx-auto max-w-[40rem]">
          {p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
          {p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '40ms' } as React.CSSProperties}>{p.title}</h2>}
          {p.description && <p className="kit-rise mt-5 text-pretty text-[18px] leading-[1.6] text-muted-foreground sm:text-[19px]" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>{p.description}</p>}
          <Reveal effect="rise" delay={140} className="mt-8 flex items-center gap-4 border-y border-border py-4">
            <div className="flex shrink-0 -space-x-2.5">
              {authors.map((x, i) => (
                <button
                  key={i}
                  type="button"
                  aria-expanded={card === i}
                  aria-label={x.name}
                  onClick={() => setCard(card === i ? null : i)}
                  className={cn('relative rounded-full ring-2 ring-background transition-transform duration-300 ease-[var(--ease-kit)] hover:z-10 hover:-translate-y-0.5 focus-visible:z-10', card === i && 'z-10 -translate-y-0.5')}
                  style={{ zIndex: card === i ? 10 : authors.length - i }}
                >
                  <Face a={x} className="size-11" />
                </button>
              ))}
            </div>
            <div className="min-w-0 text-[14.5px] leading-snug">
              <p className="text-foreground">
                <span className="text-muted-foreground">{t.written} </span>
                {authors.map((x, i) => (
                  <span key={i}>
                    <button type="button" onClick={() => setCard(card === i ? null : i)} className="relative font-medium underline decoration-border-strong underline-offset-4 transition-colors before:absolute before:-inset-x-1 before:-inset-y-3 before:content-[''] hover:decoration-current">
                      {x.name}
                    </button>
                    {i < authors.length - 2 ? ', ' : i === authors.length - 2 ? ` ${t.and} ` : ''}
                  </span>
                ))}
              </p>
              {p.date && <p className="mt-0.5 text-[13.5px] text-muted-foreground">{p.date}</p>}
            </div>
          </Reveal>
          {a && card !== null && (
            <div role="dialog" aria-label={a.name} className="kit-rise absolute inset-x-0 top-full z-20 mt-3 rounded-2xl border border-border bg-popover p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:inset-x-auto sm:left-0 sm:w-[24rem]" style={{ animationDuration: '.4s' }}>
              <div className="flex items-start gap-3.5">
                <Face a={a} className="size-14" />
                <div className="min-w-0 flex-1">
                  <p className="text-[16px] font-semibold text-popover-foreground">{a.name}</p>
                  {a.role && <p className="text-[13.5px] text-muted-foreground">{a.role}</p>}
                </div>
              </div>
              {a.bio && <p className="mt-4 text-pretty text-[14px] leading-[1.6] text-muted-foreground">{a.bio}</p>}
              <div className="mt-5 flex flex-wrap gap-2">
                <Button size="sm" variant={follow.has(card) ? 'outline' : 'primary'} iconLeft={follow.has(card) ? 'check' : 'plus'} aria-pressed={follow.has(card)} onClick={() => setFollow((s) => { const n = new Set(s); if (n.has(card)) n.delete(card); else n.add(card); return n; })}>
                  {follow.has(card) ? t.following : t.follow}
                  <span className="sr-only"> {a.name}</span>
                </Button>
                {a.href && (
                  <Button size="sm" variant="ghost" href={a.href} icon="arrow-right">
                    {t.profile}
                  </Button>
                )}
              </div>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */

export function AuthorPage(props: AuthorPageProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'profile' } = props;
  if (variant === 'contributors') return <Contributors {...props} t={t} />;
  if (variant === 'masthead') return <Masthead {...props} t={t} />;
  if (variant === 'expert-box') return <ExpertBox {...props} t={t} />;
  if (variant === 'byline') return <Byline {...props} t={t} />;
  return <Profile {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Autorenseite?

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

Welche Varianten hat Autorenseite?

5 Varianten, Profil, Autorinnen und Autoren, Impressum der Redaktion, Expertenbox und Autorenzeile. Du wählst sie mit der Prop variant, zum Beispiel variant="contributors".

Kann ich die Texte von Autorenseite übersetzen?

Ja. Autorenseite bringt 15 eigene Texte auf Deutsch und Englisch mit, etwa „Alle“, „Artikel“ und „Schreibt über“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Autorenseite einbauen?

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

Kann ich Autorenseite ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert