Zum Inhalt springen
UI Kit
Accent

Autorenbox

Wer es geschrieben hat und warum die Person es weiß. Karte am Ende, Leiste mit Folgen-Knopf, klebende Karte oder das Team.

4 VariantsEine von 5 Components in Artikel-Bausteine

Wer es geschrieben hat und warum die Person es weiß

Eine Autorenbox zeigt, wer hinter einem Text steht und warum diese Person sich auskennt. Das schafft Vertrauen bei Lesern und gibt Suchmaschinen eine klare Autorschaft mit Erfahrung, besonders wichtig bei Ratgebern zu Gesundheit, Geld und Recht.

Die „Karte am Ende“ schließt einen Text mit Porträt, Bio, Links und dem Weg zu allen Artikeln. Die „Leiste zum Folgen“ ist ein schmales Band mit einem Folgen-Knopf, der mitzählt, und einer Bio zum Aufklappen. Die „Klebende Seitenkarte“ hält Porträt, Qualifikation und Kontakt neben dem Text, am Handy über dem Text. „Team“ nennt alle Beteiligten und ihren Beitrag, mit Autorenzeile und gestapelten Porträts.

Nenn in der Bio konkrete Erfahrung, Jahre, Fälle oder Abschlüsse. Verlinke eine Autorenseite mit allen Artikeln. Nimm die Team-Fassung für Beiträge, bei denen Recherche, Fotos und Redaktion von verschiedenen Menschen kommen.

Variants

Karte am EndeNeu in 1.2

Porträt, Kurzbiografie, Links und der Weg zu allen Artikeln, für das Ende eines Textes.

Leiste zum FolgenNeu in 1.2

Eine schmale Leiste mit Folgen-Knopf, der mitzählt, und einer Kurzbiografie zum Aufklappen.

Klebende SeitenkarteNeu in 1.2

Eine Karte neben dem Text mit Porträt, Qualifikationen und Kontakt, auf dem Telefon über dem Text.

TeamNeu in 1.2

Alle hinter einem Beitrag und was jede Person beigetragen hat, mit Zeile und gestapelten Porträts.

Details

Was Autorenbox mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/author-box.tsx
Bringt mit
utilskiticonsectionrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 12 eigene Texte
Studio
Im Code anpassen
In Sammlungen
Blog oder Magazin

Props

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

  • variantoptional
    AuthorBoxVariant

    end-cardfollowsidebarteam

  • authorsPflicht
    AuthorBoxAuthor[]

    One author, or several for team

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • followersoptional
    number

    follow: how many follow already

  • bodyoptional
    string[]

    sidebar: the article text beside the card

  • contactHrefoptional
    string

    sidebar: target of the contact button

Eingebaute Texte

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

Alle 12 Texte anzeigen
aboutEnglisch About the authorDeutsch Zur Person
aboutTeamEnglisch Made byDeutsch Entstanden mit
articlesEnglisch {n} articlesDeutsch {n} Artikel
allArticlesEnglisch All {n} articlesDeutsch Alle {n} Artikel
followEnglisch FollowDeutsch Folgen
followingEnglisch FollowingDeutsch Du folgst
followersEnglisch {n} readers followDeutsch {n} Lesende folgen
moreEnglisch More about {name}Deutsch Mehr über {name}
lessEnglisch LessDeutsch Weniger
contactEnglisch Ask a questionDeutsch Frage stellen
byEnglisch ByDeutsch Von
andEnglisch andDeutsch und

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-expanded, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • 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. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 334 Zeilen
components/ui/author-box.tsx
'use client';

import { useId, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

/*
 * Who wrote this and why they know: the box about the author of an article.
 *
 *   end-card  after the text, with portrait, bio, links and the way to
 *             every article of the author
 *   follow    a slim strip with a follow button and a bio that opens
 *   sidebar   a sticky card beside the text with credentials and a contact
 *   team      several people and what each did, text, research, pictures
 */

export type AuthorBoxVariant = 'end-card' | 'follow' | 'sidebar' | 'team';

export interface AuthorLink {
  label: string;
  href: string;
  icon?: IconName;
}

export interface AuthorBoxAuthor {
  name: string;
  role?: string;
  bio?: string;
  avatar?: React.ReactNode;
  links?: AuthorLink[];
  /** As shown, e.g. 24 */
  articles?: number;
  /** Link to every article of the author */
  href?: string;
  /** sidebar: what makes the author an expert */
  credentials?: string[];
  /** team: what this person did, e.g. "Text" or "Photos" */
  contribution?: string;
}

const TEXTS = {
  en: {
    about: 'About the author',
    aboutTeam: 'Made by',
    articles: '{n} articles',
    allArticles: 'All {n} articles',
    follow: 'Follow',
    following: 'Following',
    followers: '{n} readers follow',
    more: 'More about {name}',
    less: 'Less',
    contact: 'Ask a question',
    by: 'By',
    and: 'and',
  },
  de: {
    about: 'Zur Person',
    aboutTeam: 'Entstanden mit',
    articles: '{n} Artikel',
    allArticles: 'Alle {n} Artikel',
    follow: 'Folgen',
    following: 'Du folgst',
    followers: '{n} Lesende folgen',
    more: 'Mehr über {name}',
    less: 'Weniger',
    contact: 'Frage stellen',
    by: 'Von',
    and: 'und',
  },
};
type Texts = (typeof TEXTS)['en'];

export interface AuthorBoxProps {
  variant?: AuthorBoxVariant;
  /** One author, or several for team */
  authors: AuthorBoxAuthor[];
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  /** follow: how many follow already */
  followers?: number;
  /** sidebar: the article text beside the card */
  body?: string[];
  /** sidebar: target of the contact button */
  contactHref?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const focus = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';
const fill = (s: string, v: Record<string, string | number>) => Object.entries(v).reduce((a, [k, x]) => a.replace(`{${k}}`, String(x)), s);

function Avatar({ node, className }: { node?: React.ReactNode; className?: string }) {
  return <span data-clip-ok className={cn('relative block shrink-0 overflow-hidden rounded-full bg-muted ring-1 ring-border [&>*]:size-full', className)}>{node}</span>;
}

function Links({ links, className }: { links?: AuthorLink[]; className?: string }) {
  if (!links?.length) return null;
  return (
    <ul className={cn('flex flex-wrap gap-2', className)}>
      {links.map((l) => (
        <li key={l.href}>
          <a href={l.href} className={cn('inline-flex min-h-11 items-center gap-2 rounded-lg border border-border bg-background px-3.5 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:min-h-9', focus)}>
            {l.icon && <Icon name={l.icon} size={15} className="text-muted-foreground" />}
            {l.label}
          </a>
        </li>
      ))}
    </ul>
  );
}

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

function EndCard({ p, t }: { p: AuthorBoxProps; t: Texts }) {
  const a = p.authors[0];
  if (!a) return null;
  return (
    <Reveal className="mx-auto max-w-[44rem]">
      <aside aria-label={t.about} className="relative overflow-hidden rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] sm:p-8">
        <div aria-hidden data-clip-ok className="pointer-events-none absolute -left-20 -top-24 size-64 rounded-full bg-[radial-gradient(closest-side,var(--glow),transparent)]" />
        <div className="relative grid grid-cols-[minmax(0,1fr)] gap-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:gap-7">
          <div className="flex items-center gap-4 sm:block">
            <Avatar node={a.avatar} className="size-16 sm:size-20" />
            <div className="sm:hidden">
              <p className="text-[12px] font-medium text-subtle-foreground">{p.eyebrow ?? t.about}</p>
              <p className="text-[17px] font-semibold text-foreground">{a.name}</p>
              {a.role && <p className="text-[13.5px] text-muted-foreground">{a.role}</p>}
            </div>
          </div>
          <div>
            <div className="hidden sm:block">
              <p className="text-[12px] font-medium text-subtle-foreground">{p.eyebrow ?? t.about}</p>
              <p className="mt-1 text-[19px] font-semibold tracking-[-0.015em] text-foreground">{a.name}</p>
            </div>
            {a.role && <p className="mt-0.5 hidden text-[14px] text-muted-foreground sm:block">{a.role}</p>}
            {a.bio && <p className="mt-4 max-w-[36rem] text-pretty text-[15.5px] leading-relaxed text-[color-mix(in_oklab,var(--foreground)_80%,var(--background))]">{a.bio}</p>}
            <div className="mt-6 flex flex-col gap-4 border-t border-border pt-5 sm:flex-row sm:items-center sm:justify-between">
              <Links links={a.links} />
              {a.href && (
                <a href={a.href} className={cn('group inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md text-[14px] font-medium text-foreground', focus)}>
                  {a.articles ? fill(t.allArticles, { n: a.articles }) : a.name}
                  <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover:translate-x-0.5" />
                </a>
              )}
            </div>
          </div>
        </div>
      </aside>
    </Reveal>
  );
}

function Follow({ p, t }: { p: AuthorBoxProps; t: Texts }) {
  const a = p.authors[0];
  const [on, setOn] = useState(false);
  const [open, setOpen] = useState(false);
  const id = useId();
  const locale = useLocale();
  if (!a) return null;
  const count = (p.followers ?? 0) + (on ? 1 : 0);
  return (
    <Reveal className="mx-auto max-w-[44rem]">
      <aside aria-label={t.about} className="rounded-2xl border border-border bg-surface shadow-[var(--highlight)]">
        <div className="flex flex-wrap items-center gap-4 p-4 sm:flex-nowrap sm:p-5">
          <Avatar node={a.avatar} className="size-12" />
          <div className="min-w-0 flex-1">
            <p className="truncate text-[15px] font-semibold text-foreground">{a.name}</p>
            <p className="truncate text-[13px] text-muted-foreground">
              {[a.role, a.articles ? fill(t.articles, { n: a.articles }) : null].filter(Boolean).join(' · ')}
            </p>
          </div>
          <div className="flex w-full items-center gap-2 sm:w-auto">
            {a.bio && (
              <button
                type="button"
                aria-expanded={open}
                aria-controls={id}
                onClick={() => setOpen((o) => !o)}
                className={cn('inline-flex min-h-11 flex-1 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-border px-3.5 text-[13px] font-medium text-muted-foreground sm:border-transparent transition-colors hover:bg-accent hover:text-foreground sm:min-h-10 sm:flex-none', focus)}
              >
                {open ? t.less : fill(t.more, { name: a.name.split(' ')[0] })}
                <Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', open && 'rotate-180')} />
              </button>
            )}
            <button
              type="button"
              aria-pressed={on}
              onClick={() => setOn((o) => !o)}
              className={cn(
                'inline-flex min-h-11 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-4 text-[13px] font-medium transition-[background-color,color,box-shadow,transform] duration-200 active:scale-[0.97] sm:min-h-10 sm:flex-none',
                on ? 'border border-border bg-background text-foreground' : 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]',
                focus,
              )}
            >
              <Icon name={on ? 'check' : 'plus'} size={15} className={cn(on && 'motion-safe:animate-[kit-rise_0.35s_var(--ease-kit)]')} />
              {on ? t.following : t.follow}
            </button>
          </div>
        </div>
        <div id={id} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
          <div className={cn('min-h-0 overflow-hidden', !open && 'invisible')}>
            <div className="border-t border-border px-4 pb-5 pt-4 sm:px-5 sm:pl-[5.25rem]">
              <p className="max-w-[34rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{a.bio}</p>
              <Links links={a.links} className="mt-4" />
            </div>
          </div>
        </div>
        {p.followers !== undefined && (
          <p className="border-t border-border px-4 py-2.5 text-[12.5px] tabular-nums text-muted-foreground sm:px-5" aria-live="polite">
            {fill(t.followers, { n: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(count) })}
          </p>
        )}
      </aside>
    </Reveal>
  );
}

function Sidebar({ p, t }: { p: AuthorBoxProps; t: Texts }) {
  const a = p.authors[0];
  if (!a) return null;
  return (
    <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-16">
      <div className="min-w-0">
        {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
        {p.title && <h2 className="max-w-[36rem] text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
        <div className="mt-8 max-w-[36rem] space-y-6 text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))]">
          {p.body?.map((b, i) => (
            <p key={i}>{b}</p>
          ))}
        </div>
      </div>
      <aside aria-label={t.about} className="row-start-1 lg:col-start-2 lg:row-start-1">
        <Reveal effect="rise" className="lg:sticky lg:top-24">
          <div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
            <div className="flex items-center gap-4 p-5 lg:block lg:p-0">
              <Avatar node={a.avatar} className="size-16 lg:aspect-[4/3] lg:size-auto lg:w-full lg:rounded-none lg:ring-0" />
              <div className="min-w-0 lg:border-t lg:border-border lg:px-5 lg:pt-5">
                <p className="text-[12px] font-medium text-subtle-foreground">{t.about}</p>
                <p className="mt-0.5 text-[17px] font-semibold text-foreground">{a.name}</p>
                {a.role && <p className="text-[13.5px] text-muted-foreground">{a.role}</p>}
              </div>
            </div>
            <div className="border-t border-border px-5 pb-5 pt-4 lg:border-t-0 lg:pt-3">
              {a.bio && <p className="text-pretty text-[14px] leading-relaxed text-muted-foreground">{a.bio}</p>}
              {a.credentials?.length ? (
                <Stagger as="ul" step={80} className="mt-4 space-y-2.5">
                  {a.credentials.map((c) => (
                    <li key={c} className="flex gap-2.5 text-[13.5px] leading-snug text-foreground">
                      <span className="mt-px flex size-[18px] shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
                        <Icon name="check" size={11} strokeWidth={2.4} />
                      </span>
                      {c}
                    </li>
                  ))}
                </Stagger>
              ) : null}
              {p.contactHref && (
                <a href={p.contactHref} className={cn('mt-5 flex min-h-11 items-center justify-center gap-2 rounded-lg border border-border-strong bg-background text-[14px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent', focus)}>
                  <Icon name="message-circle" size={16} />
                  {t.contact}
                </a>
              )}
            </div>
          </div>
        </Reveal>
      </aside>
    </div>
  );
}

function Team({ p, t }: { p: AuthorBoxProps; t: Texts }) {
  const names = p.authors.map((a) => a.name);
  const byline = names.length > 1 ? `${names.slice(0, -1).join(', ')} ${t.and} ${names[names.length - 1]}` : names[0];
  return (
    <div className="mx-auto max-w-[60rem]">
      <div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
        <div>
          {p.eyebrow && <Eyebrow className="mb-2">{p.eyebrow}</Eyebrow>}
          <h2 className="text-balance text-[clamp(1.375rem,1.15rem+0.8vw,1.75rem)] font-semibold tracking-[-0.02em] text-foreground">{p.title ?? t.aboutTeam}</h2>
          <p className="mt-1.5 text-[14px] text-muted-foreground">
            {t.by} {byline}
          </p>
        </div>
        <div className="flex -space-x-3" aria-hidden>
          {p.authors.map((a) => (
            <Avatar key={a.name} node={a.avatar} className="size-12 ring-[3px] ring-background transition-transform duration-300 hover:z-10 hover:-translate-y-1" />
          ))}
        </div>
      </div>
      {/* hairlines from each cell (right and bottom), the outer ones are clipped, so a short last row leaves no grey gap */}
      <Stagger as="ul" step={90} className="mt-8 grid grid-cols-[minmax(0,1fr)] overflow-hidden rounded-2xl border border-border bg-card sm:grid-cols-2 lg:grid-cols-3">
        {p.authors.map((a) => (
          <li key={a.name} className="flex flex-col p-6 shadow-[1px_0_0_var(--border),0_1px_0_var(--border)]">
            <div className="flex items-center gap-3.5">
              <Avatar node={a.avatar} className="size-11" />
              <div className="min-w-0">
                <p className="truncate text-[15px] font-semibold text-foreground">{a.name}</p>
                {a.role && <p className="truncate text-[13px] text-muted-foreground">{a.role}</p>}
              </div>
            </div>
            {a.contribution && (
              <p className="mt-5 inline-flex w-fit items-center gap-1.5 rounded-md bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-2 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{a.contribution}</p>
            )}
            {a.bio && <p className="mt-3 text-pretty text-[14px] leading-relaxed text-muted-foreground">{a.bio}</p>}
            {a.href && (
              <a href={a.href} className={cn('group mt-auto inline-flex min-h-11 items-center gap-1.5 self-start rounded-md pt-3 text-[13.5px] font-medium text-foreground', focus)}>
                {a.articles ? fill(t.articles, { n: a.articles }) : a.name}
                <Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover:translate-x-0.5" />
              </a>
            )}
          </li>
        ))}
      </Stagger>
    </div>
  );
}

/**
 * The author box of an article: a card at the end, a strip with a follow
 * button, a sticky card beside the text or the team behind a piece.
 *   <AuthorBox variant="end-card" authors={[{ name, role, bio, avatar, links, articles, href }]} />
 */
export function AuthorBox(props: AuthorBoxProps) {
  const { variant = 'end-card', className, labels } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section spacing="sm" className={className}>
      {variant === 'follow' ? <Follow p={props} t={t} /> : variant === 'sidebar' ? <Sidebar p={props} t={t} /> : variant === 'team' ? <Team p={props} t={t} /> : <EndCard p={props} t={t} />}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Autorenbox?

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

Welche Varianten hat Autorenbox?

4 Varianten, Karte am Ende, Leiste zum Folgen, Klebende Seitenkarte und Team. Du wählst sie mit der Prop variant, zum Beispiel variant="follow".

Kann ich die Texte von Autorenbox übersetzen?

Ja. Autorenbox bringt 12 eigene Texte auf Deutsch und Englisch mit, etwa „Zur Person“, „Entstanden mit“ und „Folgen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Autorenbox einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert