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.
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.
Details
What Related content brings along, read from the registry and the source.
- Category
- Article elements
- Area
- Content
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RelatedContentProps interface from components/ui/related-content.tsx, with the comments from the code.
variantoptionalRelatedContentVariantcardslistfeaturednext-prev
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeitemsrequiredRelatedItem[]allHrefoptionalstringA 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
| Key | English | German |
|---|---|---|
| article | English Article | German Artikel |
| guide | English Guide | German Ratgeber |
| video | English Video | German Video |
| page | English Page | German Seite |
| download | English Download | German Download |
| podcast | English Podcast | German Podcast |
| all | English See all | German Alle ansehen |
| previous | English Previous | German Vorheriger Beitrag |
| next | English Next | German Nächster Beitrag |
| more | English More on this topic | German Mehr zum Thema |
| scroll | English Related content, scroll sideways | German Weiterlesen, seitlich scrollen |
| back | English Show previous | German Vorherige zeigen |
| forward | English Show next | German 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
'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.