Listenartikel
Listen, die der Artikel sind. Große Ziffern mit wechselnden Bildern, eine Rangliste mit Wertung und Bewegung, eine dichte Liste mit Sprungleiste, ein Karussell zum Wischen und Orte auf einer gezeichneten Karte.
Eine von 3 Components in Redaktion
Variants
Details
Was Listenartikel mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Redaktion
- Bereich
- Content
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/listicle.tsx
- Bringt mit
- utilskiticonrevealmotionsection
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 14 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ListicleProps aus components/ui/listicle.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalListicleVariantnumberedrankedcompactcarouselwith-map
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeitemsoptionalListicleItem[]scaleoptionalnumberranked: the highest possible score, default 10
mapoptionalReact.ReactNodewith-map: the map, e.g. <DistrictMap />
Eingebaute Texte
Diese Texte schreibt Listenartikel selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 14 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| tip | Englisch Tip | Deutsch Tipp |
| contents | Englisch In this list | Deutsch In dieser Liste |
| up | Englisch Up {n} | Deutsch {n} Plätze hoch |
| up1 | Englisch Up 1 | Deutsch 1 Platz hoch |
| down | Englisch Down {n} | Deutsch {n} Plätze runter |
| down1 | Englisch Down 1 | Deutsch 1 Platz runter |
| same | Englisch Same place | Deutsch Gleicher Platz |
| new | Englisch New | Deutsch Neu |
| score | Englisch Score {s} of {m} | Deutsch Wertung {s} von {m} |
| previous | Englisch Previous | Deutsch Zurück |
| next | Englisch Next | Deutsch Weiter |
| of | Englisch {a} of {b} | Deutsch {a} von {b} |
| top | Englisch Back to the list | Deutsch Zurück zur Liste |
| item | Englisch Item {n} | Deutsch Punkt {n} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Listenartikel reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-current, aria-label, aria-live und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle region.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- 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.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 607 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Parallax, useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';
/*
* Lists that are the article: ten walks, seven places, five tools. Every
* item has a number because the list has an order, by rank or by route.
*
* numbered large numbers, the picture alternates sides and drifts while
* scrolling, a tip under each item
* ranked a ranking with scores and the movement since the last edition
* compact a dense list with a contents bar that jumps and follows
* carousel one item at a time to swipe, drag or step through
* with-map the places of the list on a drawn map, item and pin light up together
*/
export type ListicleVariant = 'numbered' | 'ranked' | 'compact' | 'carousel' | 'with-map';
export interface ListicleItem {
title: string;
text?: string;
media?: React.ReactNode;
/** A short line under the text, e.g. a tip or an address */
tip?: string;
/** Small facts, e.g. ['2.5 km', 'Café at the end'] */
meta?: string[];
href?: string;
/** ranked: a score, e.g. 9.2 */
score?: number;
/** ranked: the rank last time, for the movement arrow */
previous?: number;
/** with-map: position on the map in percent, 0 to 100 */
pin?: { x: number; y: number };
}
export interface ListicleProps {
variant?: ListicleVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
items?: ListicleItem[];
/** ranked: the highest possible score, default 10 */
scale?: number;
/** with-map: the map, e.g. <DistrictMap /> */
map?: React.ReactNode;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
tip: 'Tip',
contents: 'In this list',
up: 'Up {n}',
up1: 'Up 1',
down: 'Down {n}',
down1: 'Down 1',
same: 'Same place',
new: 'New',
score: 'Score {s} of {m}',
previous: 'Previous',
next: 'Next',
of: '{a} of {b}',
top: 'Back to the list',
item: 'Item {n}',
},
de: {
tip: 'Tipp',
contents: 'In dieser Liste',
up: '{n} Plätze hoch',
up1: '1 Platz hoch',
down: '{n} Plätze runter',
down1: '1 Platz runter',
same: 'Gleicher Platz',
new: 'Neu',
score: 'Wertung {s} von {m}',
previous: 'Zurück',
next: 'Weiter',
of: '{a} von {b}',
top: 'Zurück zur Liste',
item: 'Punkt {n}',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Shared pieces */
/* ------------------------------------------------------------------ */
function Head({ eyebrow, title, description, center, aside }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; center?: boolean; aside?: React.ReactNode }) {
if (!title && !eyebrow) return null;
return (
<div className={cn('mb-12 flex flex-col gap-6 sm:mb-16 lg:flex-row lg:items-end lg:justify-between', center && 'items-center text-center lg:flex-col lg:items-center')}>
<div className="max-w-[42rem]">
{eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
{title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
{description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
</div>
{aside}
</div>
);
}
function Picture({ media, aspect = '4 / 3', className, parallax }: { media?: React.ReactNode; aspect?: string; className?: string; parallax?: boolean }) {
if (!media) return null;
return (
<div data-clip-ok className={cn('relative overflow-hidden rounded-2xl bg-muted', className)} style={{ aspectRatio: aspect }}>
{parallax ? (
<Parallax speed={44} className="size-full scale-[1.12] [&>*]:size-full">
{media}
</Parallax>
) : (
<div className="size-full [&>*]:size-full">{media}</div>
)}
</div>
);
}
function Meta({ meta, className }: { meta?: string[]; className?: string }) {
if (!meta?.length) return null;
return (
<ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-[13.5px] text-muted-foreground', className)}>
{meta.map((m, i) => (
<li key={i} className="tabular-nums">
{m}
</li>
))}
</ul>
);
}
/* ------------------------------------------------------------------ */
/* numbered */
/* ------------------------------------------------------------------ */
function Numbered(p: ListicleProps & { t: Texts }) {
const { t } = p;
const items = p.items ?? [];
return (
<section className={cn('overflow-x-clip py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<ol className="space-y-16 sm:space-y-20 lg:space-y-28">
{items.map((it, i) => {
const flip = i % 2 === 1;
return (
<li key={i} className="grid grid-cols-[minmax(0,1fr)] items-center gap-8 md:grid-cols-2 md:gap-12 lg:gap-20">
<Reveal effect="scale" className={cn(flip && 'md:order-last')}>
<Picture media={it.media} aspect="4 / 3" parallax />
</Reveal>
<Reveal effect="rise" delay={80}>
<p className="text-[64px] font-semibold leading-none tracking-[-0.05em] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] tabular-nums sm:text-[88px]" aria-hidden>
{String(i + 1).padStart(2, '0')}
</p>
<h3 className="mt-4 text-balance text-[26px] font-semibold leading-[1.2] tracking-[-0.018em] text-foreground sm:text-[30px]">
<span className="sr-only">{i + 1}. </span>
{it.href ? (
<a href={it.href} className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-current">
{it.title}
</a>
) : (
it.title
)}
</h3>
<Meta meta={it.meta} className="mt-3" />
{it.text && <p className="mt-4 max-w-[34rem] text-pretty text-[17px] leading-[1.7] text-muted-foreground">{it.text}</p>}
{it.tip && (
<p className="mt-5 flex max-w-[34rem] gap-3 rounded-xl border border-border bg-surface px-4 py-3 text-pretty text-[14.5px] leading-[1.55] text-foreground">
<Icon name="lightbulb" size={17} className="mt-px text-primary" />
<span>
<span className="font-semibold">{t.tip}. </span>
{it.tip}
</span>
</p>
)}
</Reveal>
</li>
);
})}
</ol>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* ranked */
/* ------------------------------------------------------------------ */
function Ranked(p: ListicleProps & { t: Texts }) {
const { t } = p;
const items = p.items ?? [];
const max = p.scale ?? 10;
const locale = useLocale();
const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const listRef = useRef<HTMLOListElement>(null);
const [inView, setInView] = useState(true);
useEffect(() => {
const el = listRef.current;
if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (el.getBoundingClientRect().top < window.innerHeight * 0.85) return;
setInView(false);
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {
setInView(true);
io.disconnect();
}
}, { rootMargin: '0px 0px -15% 0px' });
io.observe(el);
return () => io.disconnect();
}, []);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<ol ref={listRef} className="border-t border-border">
{items.map((it, i) => {
const rank = i + 1;
const move = it.previous === undefined ? null : it.previous - rank;
return (
<li key={i} className="border-b border-border">
<a href={it.href ?? '#'} className="group/r grid grid-cols-[2.5rem_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 sm:grid-cols-[3.5rem_5rem_minmax(0,1fr)_minmax(0,12rem)] sm:gap-x-6 lg:grid-cols-[4rem_6.5rem_minmax(0,1fr)_minmax(0,16rem)]">
<span className="row-span-2 text-[34px] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums sm:row-span-1 sm:text-[44px]">{rank}</span>
<div className="hidden overflow-hidden rounded-xl sm:block">
<div className="transition-transform duration-700 ease-[var(--ease-kit)] group-hover/r:scale-[1.06]">
<Picture media={it.media} aspect="1 / 1" className="rounded-xl" />
</div>
</div>
<div className="min-w-0">
<h3 className="text-pretty text-[17px] font-semibold leading-[1.35] text-foreground underline decoration-transparent underline-offset-4 transition-colors duration-300 group-hover/r:decoration-current sm:text-[19px]">{it.title}</h3>
{it.text && <p className="mt-1 line-clamp-2 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{it.text}</p>}
<p className="mt-2 flex items-center gap-1.5 text-[12.5px] font-medium">
{move === null ? (
<span className="rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-1.5 py-0.5 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.new}</span>
) : move > 0 ? (
<span className="inline-flex items-center gap-1 text-success-foreground">
<Icon name="arrow-up" size={13} strokeWidth={2} />
{move === 1 ? t.up1 : t.up.replace('{n}', String(move))}
</span>
) : move < 0 ? (
<span className="inline-flex items-center gap-1 text-destructive">
<Icon name="arrow-down" size={13} strokeWidth={2} />
{move === -1 ? t.down1 : t.down.replace('{n}', String(-move))}
</span>
) : (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Icon name="minus" size={13} strokeWidth={2} />
{t.same}
</span>
)}
</p>
</div>
{it.score !== undefined && (
<div className="col-start-2 flex items-center gap-3 sm:col-start-auto" aria-label={t.score.replace('{s}', nf.format(it.score)).replace('{m}', String(max))}>
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-track" aria-hidden>
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(it.score / max, 3) : 0})`, transitionDelay: `${150 + i * 70}ms` }} />
</span>
<span className="w-10 text-right text-[17px] font-semibold tabular-nums text-foreground" aria-hidden>
{nf.format(it.score)}
</span>
</div>
)}
</a>
</li>
);
})}
</ol>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* compact */
/* ------------------------------------------------------------------ */
function Compact(p: ListicleProps & { t: Texts }) {
const { t } = p;
const uid = useId().replace(/:/g, '');
const items = p.items ?? [];
const [active, setActive] = useState(0);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const listRef = useRef<HTMLOListElement>(null);
useEffect(() => {
let frame = 0;
const update = () => {
frame = 0;
const els = [...(listRef.current?.children ?? [])] as HTMLElement[];
const line = window.innerHeight * 0.35;
let cur = 0;
els.forEach((el, i) => {
if (el.getBoundingClientRect().top <= line) cur = i;
});
// The last items can never reach the line at the end of the page
const last = els[els.length - 1];
if (last && window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 && last.getBoundingClientRect().top < window.innerHeight) cur = els.length - 1;
setActive(cur);
};
const on = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', on, { passive: true });
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', on);
};
}, [items.length]);
useEffect(() => {
const row = rowRef.current;
const a = row?.querySelector<HTMLElement>(`[data-i="${active}"]`);
if (row && a && (a.offsetLeft < row.scrollLeft || a.offsetLeft + a.offsetWidth > row.scrollLeft + row.clientWidth)) row.scrollTo({ left: a.offsetLeft - 24, behavior: 'smooth' });
}, [active, rowRef]);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="mx-auto max-w-[46rem]">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
</div>
</div>
<nav aria-label={t.contents} className="sticky top-0 z-20 border-y border-border bg-[color-mix(in_oklab,var(--background)_90%,transparent)] backdrop-blur-md">
<div className="kit-container">
<div className="mx-auto max-w-[46rem]">
<div ref={rowRef} className={cn('kit-no-scrollbar overflow-x-auto [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
<ol className="flex w-max gap-1 py-1.5">
{items.map((it, i) => (
<li key={i}>
<a href={`#${uid}-${i}`} data-i={i} aria-current={active === i ? 'true' : undefined} title={it.title} className={cn('grid size-10 place-items-center rounded-lg text-[14px] font-semibold tabular-nums transition-colors duration-300 max-sm:size-11', active === i ? 'bg-primary text-primary-foreground' : i < active ? 'text-foreground hover:bg-accent' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
<span className="sr-only">{it.title}, </span>
{i + 1}
</a>
</li>
))}
</ol>
</div>
</div>
</div>
</nav>
<div className="kit-container">
<ol ref={listRef} className="mx-auto max-w-[46rem]">
{items.map((it, i) => (
<li key={i} id={`${uid}-${i}`} className="scroll-mt-16 border-b border-border py-9 last:border-b-0">
<Reveal effect="fade" className={cn('grid grid-cols-[2.75rem_minmax(0,1fr)] gap-x-4 sm:gap-x-6', it.media ? 'sm:grid-cols-[3.5rem_minmax(0,1fr)_8rem]' : 'sm:grid-cols-[3.5rem_minmax(0,1fr)]')}>
<span className="pt-[3px] text-[24px] font-semibold leading-none tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" aria-hidden>
{i + 1}
</span>
<div className="min-w-0">
<h3 className="text-pretty text-[20px] font-semibold leading-[1.3] tracking-[-0.01em] text-foreground">
<span className="sr-only">{i + 1}. </span>
{it.title}
</h3>
{it.text && <p className="mt-2 text-pretty text-[16px] leading-[1.7] text-muted-foreground">{it.text}</p>}
<Meta meta={it.meta} className="mt-3" />
</div>
{it.media && (
<div className="col-start-2 mt-4 sm:col-start-3 sm:mt-0">
<Picture media={it.media} aspect="4 / 3" className="rounded-xl" />
</div>
)}
</Reveal>
</li>
))}
</ol>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* carousel */
/* ------------------------------------------------------------------ */
function Carousel(p: ListicleProps & { t: Texts }) {
const { t } = p;
const items = p.items ?? [];
const [i, setI] = useState(0);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const drag = useRef<{ x: number; left: number; moved: boolean } | null>(null);
// The current item follows the scroll position, whatever moved it
useEffect(() => {
const row = rowRef.current;
if (!row) return;
let frame = 0;
const on = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const kids = [...row.children] as HTMLElement[];
const mid = row.scrollLeft + 1;
let cur = 0;
kids.forEach((k, n) => {
if (k.offsetLeft - kids[0].offsetLeft <= mid + k.offsetWidth / 2) cur = n;
});
setI(cur);
});
};
row.addEventListener('scroll', on, { passive: true });
return () => {
cancelAnimationFrame(frame);
row.removeEventListener('scroll', on);
};
}, [rowRef]);
const go = (n: number) => {
const row = rowRef.current;
const kids = row ? ([...row.children] as HTMLElement[]) : [];
const k = kids[Math.max(0, Math.min(items.length - 1, n))];
if (row && k) row.scrollTo({ left: k.offsetLeft - kids[0].offsetLeft, behavior: 'smooth' });
};
return (
<section className={cn('overflow-x-clip py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<div className="flex items-center gap-3">
<span className="mr-1 text-[14px] tabular-nums text-muted-foreground lg:min-w-[4.5rem] lg:text-right" aria-live="polite">
{t.of.replace('{a}', String(i + 1)).replace('{b}', String(items.length))}
</span>
<button type="button" onClick={() => go(i - 1)} disabled={i === 0} aria-label={t.previous} className="grid size-11 place-items-center rounded-full border border-border text-foreground transition-[background-color,opacity] hover:bg-accent disabled:opacity-35">
<Icon name="arrow-left" size={17} />
</button>
<button type="button" onClick={() => go(i + 1)} disabled={i === items.length - 1} aria-label={t.next} className="grid size-11 place-items-center rounded-full border border-border text-foreground transition-[background-color,opacity] hover:bg-accent disabled:opacity-35">
<Icon name="arrow-right" size={17} />
</button>
</div>
}
/>
</div>
<div className="kit-container">
<div
ref={rowRef}
tabIndex={0}
role="region"
aria-label={typeof p.title === 'string' ? p.title : t.contents}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
go(i + 1);
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
go(i - 1);
}
}}
onPointerDown={(e) => {
if (e.pointerType !== 'mouse' || !rowRef.current) return;
drag.current = { x: e.clientX, left: rowRef.current.scrollLeft, moved: false };
}}
onPointerMove={(e) => {
if (!drag.current || !rowRef.current) return;
const dx = e.clientX - drag.current.x;
if (Math.abs(dx) > 4) drag.current.moved = true;
rowRef.current.style.scrollSnapType = 'none';
rowRef.current.scrollLeft = drag.current.left - dx;
}}
onPointerUp={() => {
if (rowRef.current) rowRef.current.style.scrollSnapType = '';
if (drag.current?.moved) go(i);
drag.current = null;
}}
onPointerLeave={() => {
if (rowRef.current) rowRef.current.style.scrollSnapType = '';
drag.current = null;
}}
className={cn('kit-no-scrollbar -mx-6 flex snap-x snap-mandatory scroll-pl-6 gap-4 overflow-x-auto px-6 pb-2 lg:scroll-pl-8 outline-none focus-visible:ring-2 focus-visible:ring-ring lg:-mx-8 lg:cursor-grab lg:px-8 lg:active:cursor-grabbing', fade)}
>
{items.map((it, n) => (
<article key={n} aria-roledescription="slide" aria-label={t.item.replace('{n}', String(n + 1))} className={cn('w-[84%] shrink-0 snap-start transition-opacity duration-500 sm:w-[60%] lg:w-[42%]', n === i ? 'opacity-100' : 'opacity-60')}>
<div className="relative">
<Picture media={it.media} aspect="4 / 3" />
<span className="absolute left-4 top-4 grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--background)_88%,transparent)] text-[18px] font-semibold tabular-nums text-foreground shadow-[var(--shadow-md)] backdrop-blur" aria-hidden>
{n + 1}
</span>
</div>
<h3 className="mt-5 text-balance text-[22px] font-semibold leading-[1.25] tracking-[-0.012em] text-foreground">{it.title}</h3>
<Meta meta={it.meta} className="mt-2" />
{it.text && <p className="mt-3 text-pretty text-[15.5px] leading-[1.65] text-muted-foreground">{it.text}</p>}
</article>
))}
</div>
<div className="mt-6 flex justify-center gap-1.5" aria-hidden>
{items.map((_, n) => (
<span key={n} className={cn('h-1.5 rounded-full transition-[width,background-color] duration-500 ease-[var(--ease-kit)]', n === i ? 'w-6 bg-primary' : 'w-1.5 bg-border-strong')} />
))}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* with-map */
/* ------------------------------------------------------------------ */
function WithMap(p: ListicleProps & { t: Texts }) {
const uid = useId().replace(/:/g, '');
const items = p.items ?? [];
const [hot, setHot] = useState<number | null>(null);
const [seen, setSeen] = useState(0);
const listRef = useRef<HTMLOListElement>(null);
useEffect(() => {
let frame = 0;
const update = () => {
frame = 0;
const els = [...(listRef.current?.children ?? [])] as HTMLElement[];
const line = window.innerHeight * 0.5;
let cur = 0;
els.forEach((el, i) => {
if (el.getBoundingClientRect().top <= line) cur = i;
});
const last = els[els.length - 1];
if (last && window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 && last.getBoundingClientRect().top < window.innerHeight) cur = els.length - 1;
setSeen(cur);
};
const on = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', on, { passive: true });
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', on);
};
}, [items.length]);
const lit = hot ?? seen;
const route = items.filter((x) => x.pin).map((x) => `${round(x.pin!.x * 10)},${round(x.pin!.y * 7)}`);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-14">
<div className="sticky top-0 z-10 -mx-6 md:static md:mx-0 lg:sticky lg:top-10 lg:self-start">
<div data-clip-ok className="relative aspect-[10/7] overflow-hidden border-y border-border bg-surface-2 shadow-[var(--shadow-sm)] md:rounded-2xl md:border">
<div className="absolute inset-0 [&>*]:size-full">{p.map}</div>
<svg viewBox="0 0 1000 700" className="pointer-events-none absolute inset-0 size-full" aria-hidden>
{route.length > 1 && <polyline points={route.join(' ')} fill="none" stroke="var(--primary)" strokeWidth="3" strokeDasharray="2 10" strokeLinecap="round" opacity="0.65" />}
</svg>
{items.map((it, i) =>
it.pin ? (
<button
key={i}
type="button"
onPointerEnter={() => setHot(i)}
onPointerLeave={() => setHot(null)}
onFocus={() => setHot(i)}
onBlur={() => setHot(null)}
onClick={() => document.getElementById(`${uid}-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
aria-label={`${i + 1}. ${it.title}`}
className="absolute grid size-11 -translate-x-1/2 -translate-y-1/2 place-items-center"
style={{ left: `${it.pin.x}%`, top: `${it.pin.y}%` }}
>
<span className={cn('grid size-8 place-items-center rounded-full border-2 text-[13px] font-semibold tabular-nums shadow-[var(--shadow-md)] transition-[transform,background-color,color,border-color] duration-300 ease-[var(--ease-kit)]', lit === i ? 'scale-[1.2] border-background bg-primary text-primary-foreground' : 'border-background bg-card text-foreground')}>
{i + 1}
</span>
</button>
) : null,
)}
</div>
</div>
<ol ref={listRef} className="divide-y divide-border border-y border-border">
{items.map((it, i) => (
<li key={i} id={`${uid}-${i}`} onPointerEnter={() => setHot(i)} onPointerLeave={() => setHot(null)} className={cn('-mx-3 scroll-mt-[60vw] rounded-xl px-3 py-6 transition-colors duration-300 lg:scroll-mt-24', lit === i && 'bg-[color-mix(in_oklab,var(--primary)_6%,transparent)]')}>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)] gap-x-3">
<span className={cn('grid size-8 place-items-center rounded-full text-[13px] font-semibold tabular-nums transition-colors duration-300', lit === i ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')} aria-hidden>
{i + 1}
</span>
<div className="min-w-0">
<h3 className="text-pretty text-[18px] font-semibold leading-[1.35] text-foreground">
<span className="sr-only">{i + 1}. </span>
{it.title}
</h3>
{it.text && <p className="mt-1.5 text-pretty text-[15.5px] leading-[1.65] text-muted-foreground">{it.text}</p>}
{it.tip && (
<p className="mt-2 flex items-center gap-1.5 text-[13.5px] text-muted-foreground">
<Icon name="map-pin" size={14} />
{it.tip}
</p>
)}
</div>
</div>
</li>
))}
</ol>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export function Listicle(props: ListicleProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'numbered' } = props;
if (variant === 'ranked') return <Ranked {...props} t={t} />;
if (variant === 'compact') return <Compact {...props} t={t} />;
if (variant === 'carousel') return <Carousel {...props} t={t} />;
if (variant === 'with-map') return <WithMap {...props} t={t} />;
return <Numbered {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Listenartikel?
Mit aiw add @ai-website/listicle. Der Befehl legt die Datei components/ui/listicle.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal, motion und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Listenartikel?
5 Varianten, Nummeriert, Rangliste, Kompakt mit Sprungleiste, Karussell und Mit Karte. Du wählst sie mit der Prop variant, zum Beispiel variant="ranked".
Kann ich die Texte von Listenartikel übersetzen?
Ja. Listenartikel bringt 14 eigene Texte auf Deutsch und Englisch mit, etwa „Tipp“, „In dieser Liste“ und „1 Platz hoch“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Listenartikel einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Listenartikel mit aiw_components und installieren sie mit aiw_add @ai-website/listicle. Nenn im Prompt die Variante, etwa variant="numbered", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Listenartikel ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Listicle-Code zum Kopieren.