Author page
The people who write. An author profile with their articles, the directory of contributors, the masthead of an editorial team, a box with writer, reviewer and checks, and a byline for several authors.
One of 2 components in Authors and interviews
Variants
Details
What Author page brings along, read from the registry and the source.
- Category
- Authors and interviews
- Area
- Content
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/author-page.tsx
- Brings along
- utilskitbuttoniconnumber-tickerrevealmotionsection
- npm packages
- None
- Languages
- English and German, 15 texts of its own
- Studio
- All 5 variants editable
- In collections
- Blog or magazine
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AuthorPageProps interface from components/ui/author-page.tsx, with the comments from the code.
variantoptionalAuthorPageVariantprofilecontributorsmastheadexpert-boxbyline
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeauthoroptionalAuthorprofile: the author; expert-box: the writer
revieweroptionalAuthorexpert-box: the reviewer
authorsoptionalAuthor[]contributors, byline
articlesoptionalAuthorArticle[]profile
statsoptional{ value: number; label: string; suffix?: string }[]profile: figures, e.g. [{ value: 214, label: 'articles' }]
groupsoptionalMastheadGroup[]masthead
footnoteoptionalstringmasthead: address or legal line under the team
checksoptional{ label: string; value: string }[]expert-box: checks done, e.g. sources, last review
methodoptionalstringexpert-box: how the editorial team works
dateoptionalstringbyline: date line
Built-in texts
Author page writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 15 texts
| Key | English | German |
|---|---|---|
| all | English All | German Alle |
| articles | English Articles | German Artikel |
| writesAbout | English Writes about | German Schreibt über |
| minutes | English {n} min | German {n} Min. |
| latest | English Latest | German Zuletzt |
| articlesN | English {n} articles | German {n} Artikel |
| beat | English Beat | German Ressort |
| showing | English {n} articles | German {n} Artikel |
| written | English Written by | German Geschrieben von |
| reviewed | English Reviewed by | German Geprüft von |
| how | English How we checked this text | German So haben wir diesen Text geprüft |
| follow | English Follow | German Folgen |
| following | English Following | German Du folgst |
| and | English and | German und |
| profile | English View profile | German Profil ansehen |
Accessibility
Read from the source of the component.
- Author page handles the keys Escape, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-controls, aria-expanded, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles dialog, radio and radiogroup.
- 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).
- 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, 577 lines
'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} />;
}Frequent questions
How do I install Author page?
With aiw add @ai-website/author-page. The command puts components/ui/author-page.tsx into your project, the code is yours from then on. It brings along button, icon, number-ticker, reveal, motion and section. It needs no further npm packages.
Which variants does Author page have?
5 variants, Profile, Contributors, Masthead, Expert box and Byline. You pick one with the variant prop, for example variant="contributors".
Can I translate the texts of Author page?
Yes. Author page carries 15 texts of its own in English and German, such as “All”, “Articles” and “Writes about”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Author page?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Author page with aiw_components and install it with aiw_add @ai-website/author-page. Name the variant in your prompt, for example variant="profile", and the assistant writes your content straight into the props.
Can I adjust Author page without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching AuthorPage code to copy.