Ähnliche Beiträge und Weiterlesen
Was man als Nächstes liest, ansieht oder lädt, mit Art und Dauer. Karten, nummerierte Liste, ein großer Tipp oder vor und zurück.
Was du als Nächstes liest
Am Ende eines Artikels entscheiden Leser, ob sie bleiben. „Weiterlesen“ gibt ihnen den nächsten Schritt, mit der Art des Inhalts, Artikel, Video oder Download, und der Dauer, die Wahl fällt also leicht.
„Karten“ zeigt drei Karten mit Bild, Art und Dauer, am Handy eine seitliche Reihe mit Knöpfen. Die „Nummerierte Liste“ ist eine kompakte Liste in zwei Spalten mit Art und Dauer, für das Ende eines Artikels. Der „Große Tipp“ setzt eine Empfehlung groß auf ihr Bild neben eine kurze Liste mit kleinen Bildern. „Vor und zurück“ macht aus dem vorigen und dem nächsten Beitrag zwei große Links, darunter mehr zum Thema.
Drei Vorschläge genügen, dann bleibt die Wahl klar. Wähl sie nach Thema statt nach Datum. „Vor und zurück“ passt zu Serien und Dokumentation, die Karten zu einem Blog.
Details
Was Weiterlesen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Artikel-Bausteine
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/related-content.tsx
- Bringt mit
- utilskiticonsectionrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 13 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Blog oder Magazin
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle RelatedContentProps aus components/ui/related-content.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalRelatedContentVariantcardslistfeaturednext-prev
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeitemsPflichtRelatedItem[]allHrefoptionalstringA link to everything, beside the title
Eingebaute Texte
Diese Texte schreibt Weiterlesen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 13 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| article | Englisch Article | Deutsch Artikel |
| guide | Englisch Guide | Deutsch Ratgeber |
| video | Englisch Video | Deutsch Video |
| page | Englisch Page | Deutsch Seite |
| download | Englisch Download | Deutsch Download |
| podcast | Englisch Podcast | Deutsch Podcast |
| all | Englisch See all | Deutsch Alle ansehen |
| previous | Englisch Previous | Deutsch Vorheriger Beitrag |
| next | Englisch Next | Deutsch Nächster Beitrag |
| more | Englisch More on this topic | Deutsch Mehr zum Thema |
| scroll | Englisch Related content, scroll sideways | Deutsch Weiterlesen, seitlich scrollen |
| back | Englisch Show previous | Deutsch Vorherige zeigen |
| forward | Englisch Show next | Deutsch Nächste zeigen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- 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).
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 293 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Weiterlesen?
Mit aiw add @ai-website/related-content. Der Befehl legt die Datei components/ui/related-content.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 Weiterlesen?
4 Varianten, Karten, Nummerierte Liste, Großer Tipp und Vor und zurück. Du wählst sie mit der Prop variant, zum Beispiel variant="list".
Kann ich die Texte von Weiterlesen übersetzen?
Ja. Weiterlesen bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Artikel“, „Ratgeber“ und „Video“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Weiterlesen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Weiterlesen mit aiw_components und installieren sie mit aiw_add @ai-website/related-content. Nenn im Prompt die Variante, etwa variant="cards", dann schreibt der Assistent deine Inhalte direkt in die Props.