Skip to content
UI Kit
Accent color

Author bio box

Who wrote it and why they know. A card at the end, a strip with follow button, a sticky card beside the text or the team.

4 variantsOne of 5 components in Article elements

Who wrote it and why they know

An author box shows who stands behind a text and why they know the subject. That builds trust with readers and gives search engines a clear author with experience, which matters most for advice on health, money and law.

End card closes a text with portrait, bio, links and the way to every article. Follow strip is a slim bar with a follow button that counts along and a bio that opens. Sticky sidebar keeps a card with portrait, credentials and a contact beside the text, above the text on phones. Team names everyone behind a piece and what each did, with a byline and stacked portraits.

Name concrete experience in the bio, years, cases or qualifications. Link to an author page with all articles. Use the team version for pieces where research, photos and editing come from different people.

Variants

End cardNew in 1.2

Portrait, bio, links and the way to every article, for the end of a text.

Follow stripNew in 1.2

A slim strip with a follow button that counts along and a bio that opens.

Sticky sidebarNew in 1.2

A card beside the text with portrait, credentials and a contact, above the text on phones.

TeamNew in 1.2

Everyone behind a piece and what each did, with a byline and stacked portraits.

Details

What Author box brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/author-box.tsx
Brings along
utilskiticonsectionrevealmotion
npm packages
None
Languages
English and German, 12 texts of its own
Studio
Adjust in code
In collections
Blog or magazine

Props

The AuthorBoxProps interface from components/ui/author-box.tsx, with the comments from the code.

  • variantoptional
    AuthorBoxVariant

    end-cardfollowsidebarteam

  • authorsrequired
    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

Built-in texts

Author box writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 12 texts
aboutEnglish About the authorGerman Zur Person
aboutTeamEnglish Made byGerman Entstanden mit
articlesEnglish {n} articlesGerman {n} Artikel
allArticlesEnglish All {n} articlesGerman Alle {n} Artikel
followEnglish FollowGerman Folgen
followingEnglish FollowingGerman Du folgst
followersEnglish {n} readers followGerman {n} Lesende folgen
moreEnglish More about {name}German Mehr über {name}
lessEnglish LessGerman Weniger
contactEnglish Ask a questionGerman Frage stellen
byEnglish ByGerman Von
andEnglish andGerman und

Accessibility

Read from the source of the component.

  • Sets aria-controls, aria-expanded, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Announces changes through a live region without moving focus.
  • 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. 1 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, 334 lines
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>
  );
}

Frequent questions

How do I install Author box?

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

Which variants does Author box have?

4 variants, End card, Follow strip, Sticky sidebar and Team. You pick one with the variant prop, for example variant="follow".

Can I translate the texts of Author box?

Yes. Author box carries 12 texts of its own in English and German, such as “About the author”, “Made by” and “Follow”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Author box?

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

Often searched

Jump to

Copied to the clipboard