Skip to content
UI Kit
Accent color

Related posts and read next

What to read, watch or download next, with kinds and times. Cards, a numbered list, one large pick or previous and next.

4 variantsOne of 5 components in Article elements

What to read next

The end of an article is the moment readers decide whether to stay. Related content gives them the next step, with the kind of piece, article, video or download, and the time it takes, so the choice is easy.

Cards shows three cards with picture, kind and time, a sideways row with buttons on phones. Numbered list is a compact list in two columns with kind and time, for the end of an article. Featured pick sets one large pick on its picture beside a short list with small pictures. Previous and next turns the neighbouring pieces into two large links with more on the topic below.

Three suggestions are enough, the choice stays clear. Pick them by topic rather than by date. Previous and next suits series and documentation, the cards suit a blog.

Variants

Details

What Related content brings along, read from the registry and the source.

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

Props

The RelatedContentProps interface from components/ui/related-content.tsx, with the comments from the code.

  • variantoptional
    RelatedContentVariant

    cardslistfeaturednext-prev

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsrequired
    RelatedItem[]
  • allHrefoptional
    string

    A link to everything, beside the title

Built-in texts

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

Show all 13 texts
articleEnglish ArticleGerman Artikel
guideEnglish GuideGerman Ratgeber
videoEnglish VideoGerman Video
pageEnglish PageGerman Seite
downloadEnglish DownloadGerman Download
podcastEnglish PodcastGerman Podcast
allEnglish See allGerman Alle ansehen
previousEnglish PreviousGerman Vorheriger Beitrag
nextEnglish NextGerman Nächster Beitrag
moreEnglish More on this topicGerman Mehr zum Thema
scrollEnglish Related content, scroll sidewaysGerman Weiterlesen, seitlich scrollen
backEnglish Show previousGerman Vorherige zeigen
forwardEnglish Show nextGerman Nächste zeigen

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • 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).
  • Moves with the scroll, its progress follows the position on the page.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 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, 293 lines
components/ui/related-content.tsx
'use client';

import { useRef, useState, useEffect } from 'react';
import { cn, 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';

/*
 * What to read, watch or open next, at the end of an article or page.
 * Items can be of different kinds (article, guide, video, page, download,
 * podcast), each with its own icon and label.
 *
 *   cards     three cards with picture, kind and time, a sideways row on phones and tablets
 *   list      a compact numbered list in two columns, for the end of a text
 *   featured  one large pick beside a short list with small pictures
 *   next-prev the previous and next piece as two large links, more below
 */

export type RelatedContentVariant = 'cards' | 'list' | 'featured' | 'next-prev';
export type RelatedKind = 'article' | 'guide' | 'video' | 'page' | 'download' | 'podcast';

export interface RelatedItem {
  title: string;
  href: string;
  kind?: RelatedKind;
  excerpt?: string;
  /** As shown, e.g. "6 min read", "12:40" or "PDF, 2 MB" */
  meta?: string;
  /** A picture, e.g. <img … /> */
  media?: React.ReactNode;
}

const TEXTS = {
  en: { article: 'Article', guide: 'Guide', video: 'Video', page: 'Page', download: 'Download', podcast: 'Podcast', all: 'See all', previous: 'Previous', next: 'Next', more: 'More on this topic', scroll: 'Related content, scroll sideways', back: 'Show previous', forward: 'Show next' },
  de: { article: 'Artikel', guide: 'Ratgeber', video: 'Video', page: 'Seite', download: 'Download', podcast: 'Podcast', all: 'Alle ansehen', previous: 'Vorheriger Beitrag', next: 'Nächster Beitrag', more: 'Mehr zum Thema', scroll: 'Weiterlesen, seitlich scrollen', back: 'Vorherige zeigen', forward: 'Nächste zeigen' },
};
type Texts = (typeof TEXTS)['en'];

const KIND_ICON: Record<RelatedKind, IconName> = { article: 'newspaper', guide: 'book-open', video: 'play-circle', page: 'file', download: 'download', podcast: 'headphones' };

export interface RelatedContentProps {
  variant?: RelatedContentVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  items: RelatedItem[];
  /** A link to everything, beside the title */
  allHref?: string;
  /** Heading level of the item titles follows the section title */
  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';

function Kind({ kind = 'article', t, className }: { kind?: RelatedKind; t: Texts; className?: string }) {
  return (
    <span className={cn('inline-flex items-center gap-1.5 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]', className)}>
      <Icon name={KIND_ICON[kind]} size={14} />
      {t[kind]}
    </span>
  );
}

function Picture({ media, className }: { media?: React.ReactNode; className?: string }) {
  return (
    <span data-clip-ok className={cn('relative block overflow-hidden bg-muted', className)}>
      <span className="absolute inset-0 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none [&>*]:size-full">{media}</span>
    </span>
  );
}

function Head({ p, t }: { p: RelatedContentProps; t: Texts }) {
  if (!p.title && !p.eyebrow && !p.allHref) return null;
  return (
    <div className="mb-10 flex flex-col gap-5 sm:mb-12 sm:flex-row sm:items-end sm:justify-between">
      <div className="max-w-[40rem]">
        {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
        {p.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
        {p.description && <p className="mt-4 text-pretty text-lead text-muted-foreground">{p.description}</p>}
      </div>
      {p.allHref && (
        <a href={p.allHref} className={cn('group inline-flex min-h-11 shrink-0 items-center gap-1.5 self-start rounded-md text-[14px] font-medium text-foreground sm:self-auto', focus)}>
          {t.all}
          <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover:translate-x-0.5" />
        </a>
      )}
    </div>
  );
}

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

function Cards({ p, t }: { p: RelatedContentProps; t: Texts }) {
  const row = useRef<HTMLUListElement>(null);
  const [edge, setEdge] = useState<'start' | 'middle' | 'end'>('start');
  useEffect(() => {
    const el = row.current;
    if (!el) return;
    const update = () => {
      const max = el.scrollWidth - el.clientWidth;
      setEdge(el.scrollLeft < 8 ? 'start' : el.scrollLeft > max - 8 ? 'end' : 'middle');
    };
    update();
    el.addEventListener('scroll', update, { passive: true });
    return () => el.removeEventListener('scroll', update);
  }, []);
  const step = (dir: 1 | -1) => {
    const el = row.current;
    if (el) el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: 'smooth' });
  };
  return (
    <>
      <Head p={p} t={t} />
      <ul
        ref={row}
        tabIndex={0}
        aria-label={t.scroll}
        className={cn(
          'kit-no-scrollbar -mx-6 flex snap-x snap-mandatory scroll-px-6 gap-4 overflow-x-auto px-6 pb-2 outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring sm:gap-5 lg:mx-0 lg:grid lg:grid-cols-3 lg:overflow-visible lg:px-0 lg:pb-0',
          edge === 'start' ? 'max-lg:kit-fade-end' : edge === 'end' ? 'max-lg:kit-fade-start' : 'max-lg:kit-fade-x',
        )}
      >
        {p.items.map((it, i) => (
          <Reveal as="li" key={it.href + i} delay={i * 80} className="w-[82%] shrink-0 snap-start sm:w-[44%] lg:w-auto">
            <a href={it.href} className={cn('group flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0', focus)}>
              <Picture media={it.media} className="aspect-[16/10] border-b border-border" />
              <div className="flex flex-1 flex-col p-5">
                <Kind kind={it.kind} t={t} />
                <h3 className="mt-3 text-balance text-[17px] font-semibold leading-snug tracking-[-0.015em] text-foreground">{it.title}</h3>
                {it.excerpt && <span className="mt-2 line-clamp-3 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{it.excerpt}</span>}
                <span className="mt-auto flex items-center justify-between gap-3 pt-5 text-[13px] text-muted-foreground">
                  <span>{it.meta}</span>
                  <Icon name="arrow-up-right" size={16} className="text-foreground transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
                </span>
              </div>
            </a>
          </Reveal>
        ))}
      </ul>
      {p.items.length > 1 && (
        <div className="mt-4 flex justify-end gap-2 lg:hidden">
          <button type="button" aria-label={t.back} disabled={edge === 'start'} onClick={() => step(-1)} className={cn('flex size-11 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground transition-opacity disabled:cursor-default disabled:opacity-40', focus)}>
            <Icon name="arrow-left" size={16} />
          </button>
          <button type="button" aria-label={t.forward} disabled={edge === 'end'} onClick={() => step(1)} className={cn('flex size-11 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-foreground transition-opacity disabled:cursor-default disabled:opacity-40', focus)}>
            <Icon name="arrow-right" size={16} />
          </button>
        </div>
      )}
    </>
  );
}

function List({ p, t }: { p: RelatedContentProps; t: Texts }) {
  return (
    <div className="mx-auto max-w-[60rem]">
      <Head p={p} t={t} />
      <Stagger as="ol" step={60} className="grid grid-cols-[minmax(0,1fr)] border-t border-border md:grid-cols-2 md:gap-x-10">
        {p.items.map((it, i) => (
          <li key={it.href + i} className="border-b border-border">
            <a href={it.href} className={cn('group grid min-h-11 grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-3 rounded-md py-5', focus)}>
              <span className="pt-0.5 text-[13px] font-medium tabular-nums text-subtle-foreground transition-colors group-hover:text-primary">{String(i + 1).padStart(2, '0')}</span>
              <div className="min-w-0">
                <h3 className="text-pretty text-[16px] font-semibold leading-snug text-foreground decoration-border-strong underline-offset-4 group-hover:underline">{it.title}</h3>
                <span className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
                  <Kind kind={it.kind} t={t} />
                  {it.meta && <span className="text-[12.5px] text-muted-foreground">{it.meta}</span>}
                </span>
              </div>
              <Icon name="arrow-right" size={16} className="mt-1 text-muted-foreground transition-[transform,color] duration-300 group-hover:translate-x-1 group-hover:text-foreground" />
            </a>
          </li>
        ))}
      </Stagger>
    </div>
  );
}

function Featured({ p, t }: { p: RelatedContentProps; t: Texts }) {
  const [first, ...rest] = p.items;
  if (!first) return null;
  return (
    <>
      <Head p={p} t={t} />
      <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] lg:gap-10">
        <Reveal effect="scale">
          <a href={first.href} className={cn('group relative flex h-full min-h-[22rem] flex-col justify-end overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-md)] sm:min-h-[26rem]', focus)}>
            <Picture media={first.media} className="!absolute inset-0" />
            <span aria-hidden className="absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.88),rgb(0_0_0/0.62)_45%,rgb(0_0_0/0.1)_85%)]" />
            <div className="dark relative p-6 sm:p-8">
              <Kind kind={first.kind} t={t} className="text-white/85" />
              <h3 className="mt-3 max-w-[30rem] text-balance text-[clamp(1.375rem,1.1rem+1vw,1.875rem)] font-semibold leading-tight tracking-[-0.025em] text-white">{first.title}</h3>
              {first.excerpt && <span className="mt-3 block max-w-[32rem] text-pretty text-[15px] leading-relaxed text-white/80">{first.excerpt}</span>}
              <span className="mt-5 inline-flex items-center gap-2 text-[14px] font-medium text-white">
                {first.meta}
                <Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover:translate-x-1" />
              </span>
            </div>
          </a>
        </Reveal>
        <Stagger as="ul" step={90} className="flex flex-col divide-y divide-border">
          {rest.map((it, i) => (
            <li key={it.href + i} className="first:pt-0 [&:not(:first-child)]:pt-4 [&:not(:last-child)]:pb-4 lg:flex lg:flex-1 lg:items-center">
              <a href={it.href} className={cn('group grid w-full grid-cols-[6.5rem_minmax(0,1fr)] items-center gap-4 rounded-lg sm:grid-cols-[8rem_minmax(0,1fr)]', focus)}>
                <Picture media={it.media} className="aspect-[4/3] rounded-lg border border-border" />
                <div className="min-w-0">
                  <Kind kind={it.kind} t={t} />
                  <h3 className="mt-1.5 text-pretty text-[15.5px] font-semibold leading-snug text-foreground decoration-border-strong underline-offset-4 group-hover:underline">{it.title}</h3>
                  {it.meta && <span className="mt-1 block text-[12.5px] text-muted-foreground">{it.meta}</span>}
                </div>
              </a>
            </li>
          ))}
        </Stagger>
      </div>
    </>
  );
}

function NextPrev({ p, t }: { p: RelatedContentProps; t: Texts }) {
  const [prev, next, ...more] = p.items;
  const pager = (it: RelatedItem | undefined, dir: 'prev' | 'next') => {
    if (!it) return <span className="hidden sm:block" />;
    const isNext = dir === 'next';
    return (
      <a
        href={it.href}
        rel={isNext ? 'next' : 'prev'}
        className={cn(
          'group relative flex min-h-36 flex-col justify-between gap-6 overflow-hidden rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-7',
          isNext && 'sm:text-right',
          focus,
        )}
      >
        <span aria-hidden className={cn('pointer-events-none absolute inset-y-0 w-1/2 opacity-0 transition-opacity duration-500 group-hover:opacity-100', isNext ? 'right-0 bg-[radial-gradient(closest-side_at_100%_50%,var(--glow),transparent)]' : 'left-0 bg-[radial-gradient(closest-side_at_0%_50%,var(--glow),transparent)]')} />
        <span className={cn('relative flex items-center gap-2 text-[13px] font-medium text-muted-foreground', isNext && 'sm:justify-end')}>
          {!isNext && <Icon name="arrow-left" size={16} className="transition-transform duration-300 group-hover:-translate-x-1" />}
          {isNext ? t.next : t.previous}
          {isNext && <Icon name="arrow-right" size={16} className="transition-transform duration-300 group-hover:translate-x-1" />}
        </span>
        <div className="relative">
          <h3 className="text-balance text-[clamp(1.125rem,1rem+0.6vw,1.375rem)] font-semibold leading-snug tracking-[-0.02em] text-foreground">{it.title}</h3>
          <span className={cn('mt-2 flex items-center gap-3 text-[13px] text-muted-foreground', isNext && 'sm:justify-end')}>
            <Kind kind={it.kind} t={t} />
            {it.meta && <span>{it.meta}</span>}
          </span>
        </div>
      </a>
    );
  };
  return (
    <nav aria-label={typeof p.title === 'string' ? p.title : t.more} className="mx-auto max-w-[60rem]">
      <Head p={p} t={t} />
      <Reveal className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 sm:gap-5">
        {pager(prev, 'prev')}
        {pager(next, 'next')}
      </Reveal>
      {more.length > 0 && (
        <Reveal delay={150} className="mt-10 border-t border-border pt-6">
          <p className="text-[13px] font-medium text-subtle-foreground">{t.more}</p>
          <ul className="mt-2 flex flex-col sm:flex-row sm:flex-wrap sm:gap-x-8">
            {more.map((it, i) => (
              <li key={it.href + i}>
                <a href={it.href} className={cn('group inline-flex min-h-11 items-center gap-2 rounded-md text-[14.5px] text-foreground', focus)}>
                  <Icon name={KIND_ICON[it.kind ?? 'article']} size={15} className="text-muted-foreground" />
                  <span className="underline decoration-border-strong underline-offset-4 transition-colors group-hover:decoration-foreground">{it.title}</span>
                </a>
              </li>
            ))}
          </ul>
        </Reveal>
      )}
    </nav>
  );
}

/**
 * Related content for the end of a page: cards, a numbered list, one large
 * pick with a short list, or previous and next with more below.
 *   <RelatedContent variant="cards" title="Read next" items={[{ title, href, kind: 'guide', meta: '6 min', media }]} />
 */
export function RelatedContent(props: RelatedContentProps) {
  const { variant = 'cards', className, labels } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section spacing="sm" className={cn('border-t border-border', className)}>
      {variant === 'list' ? <List p={props} t={t} /> : variant === 'featured' ? <Featured p={props} t={t} /> : variant === 'next-prev' ? <NextPrev p={props} t={t} /> : <Cards p={props} t={t} />}
    </Section>
  );
}

Frequent questions

How do I install Related content?

With aiw add @ai-website/related-content. The command puts components/ui/related-content.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 Related content have?

4 variants, Cards, Numbered list, Featured pick and Previous and next. You pick one with the variant prop, for example variant="list".

Can I translate the texts of Related content?

Yes. Related content carries 13 texts of its own in English and German, such as “Article”, “Guide” and “Video”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Related content?

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

Often searched

Jump to

Copied to the clipboard