Listicle
Lists that are the article. Large numbers with pictures that alternate, a ranking with scores and movement, a dense list with a contents bar, a carousel to swipe and places on a drawn map.
One of 3 components in Editorial
Variants
Details
What Listicle brings along, read from the registry and the source.
- Category
- Editorial
- Area
- Content
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/listicle.tsx
- Brings along
- utilskiticonrevealmotionsection
- npm packages
- None
- Languages
- English and German, 14 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ListicleProps interface from components/ui/listicle.tsx, with the comments from the code.
variantoptionalListicleVariantnumberedrankedcompactcarouselwith-map
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeitemsoptionalListicleItem[]scaleoptionalnumberranked: the highest possible score, default 10
mapoptionalReact.ReactNodewith-map: the map, e.g. <DistrictMap />
Built-in texts
Listicle writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 14 texts
| Key | English | German |
|---|---|---|
| tip | English Tip | German Tipp |
| contents | English In this list | German In dieser Liste |
| up | English Up {n} | German {n} Plätze hoch |
| up1 | English Up 1 | German 1 Platz hoch |
| down | English Down {n} | German {n} Plätze runter |
| down1 | English Down 1 | German 1 Platz runter |
| same | English Same place | German Gleicher Platz |
| new | English New | German Neu |
| score | English Score {s} of {m} | German Wertung {s} von {m} |
| previous | English Previous | German Zurück |
| next | English Next | German Weiter |
| of | English {a} of {b} | German {a} von {b} |
| top | English Back to the list | German Zurück zur Liste |
| item | English Item {n} | German Punkt {n} |
Accessibility
Read from the source of the component.
- Listicle handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-current, aria-label, aria-live and aria-roledescription, so screen readers announce purpose and state.
- Uses the role region.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- 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.
- 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, 607 lines
'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} />;
}Frequent questions
How do I install Listicle?
With aiw add @ai-website/listicle. The command puts components/ui/listicle.tsx into your project, the code is yours from then on. It brings along icon, reveal, motion and section. It needs no further npm packages.
Which variants does Listicle have?
5 variants, Numbered, Ranking, Compact with contents, Carousel and With map. You pick one with the variant prop, for example variant="ranked".
Can I translate the texts of Listicle?
Yes. Listicle carries 14 texts of its own in English and German, such as “Tip”, “In this list” and “Up 1”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Listicle?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Listicle with aiw_components and install it with aiw_add @ai-website/listicle. Name the variant in your prompt, for example variant="numbered", and the assistant writes your content straight into the props.
Can I adjust Listicle without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Listicle code to copy.