History timeline
The history of a company, told large. Eras with a line that fills, a year that counts along, a ruler of years to drag, decade tiles and the jubilee with the years set huge. Old pictures can appear as archive prints.
One of 2 components in About
Variants
Details
What History timeline brings along, read from the registry and the source.
- Category
- About
- Area
- Content
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/history-timeline.tsx
- Brings along
- utilskiticonnumber-tickerrevealmotionsection
- npm packages
- None
- Languages
- English and German, 9 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The HistoryTimelineProps interface from components/ui/history-timeline.tsx, with the comments from the code.
variantoptionalHistoryTimelineVarianterasyear-countertrackdecadesanniversary
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeeventsoptionalHistoryEvent[]erasoptionalHistoryEra[]foundedoptionalnumberanniversary: the founding year, the current year is computed from `now`
nowoptionalnumberanniversary: the year of the jubilee, e.g. 2026
quoteoptional{ text: string; author: string; role?: string; media?: React.ReactNode }anniversary: a sentence from the founders
Built-in texts
History timeline writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 9 texts
| Key | English | German |
|---|---|---|
| years | English years | German Jahre |
| previous | English Previous year | German Vorheriges Jahr |
| next | English Next year | German Nächstes Jahr |
| timeline | English Years | German Jahre |
| since | English since {y} | German seit {y} |
| from | English Since {y} | German Seit {y} |
| of | English {a} of {b} | German {a} von {b} |
| milestones | English Milestones | German Meilensteine |
| decade | English The {d}s | German Die {d}er |
Accessibility
Read from the source of the component.
- History timeline handles the keys Arrow left, Arrow right, Home and End itself.
- Sets aria-controls, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- 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. 6 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, 549 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, Stagger, useScrollFade } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';
/*
* The history of a company, told large. Years are data, pictures are nodes,
* old pictures can be shown as archive prints.
*
* eras the history in chapters, each era with its picture and a line
* that fills while you read through it
* year-counter a large year stands still and counts along while the events
* pass beside it
* track a ruler of years to drag, swipe or step through, the chosen
* year opens above it
* decades one tile per decade with its picture and milestones, the
* years count up once
* anniversary the jubilee: the number of years large, the founding quote and
* a strip of milestones that draws itself
*/
export type HistoryTimelineVariant = 'eras' | 'year-counter' | 'track' | 'decades' | 'anniversary';
export interface HistoryEvent {
year: number;
title: string;
text?: string;
media?: React.ReactNode;
/** Show the picture as an archive print, black and white with grain */
archive?: boolean;
/** A number that belongs to the year, e.g. 40 employees */
figure?: { value: string; label: string };
}
export interface HistoryEra {
/** e.g. 1951 to 1969 */
from: number;
to?: number;
title: string;
text?: string;
media?: React.ReactNode;
archive?: boolean;
events: HistoryEvent[];
}
export interface HistoryTimelineProps {
variant?: HistoryTimelineVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
events?: HistoryEvent[];
eras?: HistoryEra[];
/** anniversary: the founding year, the current year is computed from `now` */
founded?: number;
/** anniversary: the year of the jubilee, e.g. 2026 */
now?: number;
/** anniversary: a sentence from the founders */
quote?: { text: string; author: string; role?: string; media?: React.ReactNode };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
years: 'years',
previous: 'Previous year',
next: 'Next year',
timeline: 'Years',
since: 'since {y}',
from: 'Since {y}',
of: '{a} of {b}',
milestones: 'Milestones',
decade: 'The {d}s',
},
de: {
years: 'Jahre',
previous: 'Vorheriges Jahr',
next: 'Nächstes Jahr',
timeline: 'Jahre',
since: 'seit {y}',
from: 'Seit {y}',
of: '{a} von {b}',
milestones: 'Meilensteine',
decade: 'Die {d}er',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Shared pieces */
/* ------------------------------------------------------------------ */
/** A picture, old ones as archive prints: grey, a touch warm, a little grain */
function Picture({ media, archive, aspect = '4 / 3', className, parallax }: { media?: React.ReactNode; archive?: boolean; aspect?: string; className?: string; parallax?: boolean }) {
if (!media) return null;
const inner = <div className={cn('size-full [&>*]:size-full', archive && 'contrast-[1.05] grayscale-[0.92] sepia-[0.18]')}>{media}</div>;
return (
<div data-clip-ok className={cn('relative overflow-hidden rounded-xl bg-muted', className)} style={{ aspectRatio: aspect }}>
{parallax ? (
<Parallax speed={36} className="size-full scale-[1.1]">
{inner}
</Parallax>
) : (
inner
)}
{archive && (
<span aria-hidden className="pointer-events-none absolute inset-0 opacity-[0.16] mix-blend-multiply [background-image:radial-gradient(rgb(0_0_0/0.6)_0.6px,transparent_0.8px)] [background-size:3px_3px]" />
)}
</div>
);
}
function Head({ eyebrow, title, description, center }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; center?: boolean }) {
if (!title && !eyebrow) return null;
return (
<div className={cn('mb-12 max-w-[42rem] sm:mb-16', center && 'mx-auto text-center')}>
{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={cn('kit-rise mt-4 text-pretty text-lead text-muted-foreground', center && 'mx-auto max-w-[36rem]')} style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
</div>
);
}
/** 0 to 1 while the element passes a line at 45 % of the window */
function useFill<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [v, setV] = useState(1);
useEffect(() => {
const el = ref.current;
if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let frame = 0;
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const line = window.innerHeight * 0.45;
// At the end of the page the bottom never reaches the line, the fill completes once it is in view
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 && r.bottom <= window.innerHeight) return setV(1);
setV(round(Math.min(1, Math.max(0, (line - r.top) / Math.max(1, r.height))), 3));
};
const on = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', on, { passive: true });
window.addEventListener('resize', on);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', on);
window.removeEventListener('resize', on);
};
}, []);
return [ref, v] as const;
}
/* ------------------------------------------------------------------ */
/* eras */
/* ------------------------------------------------------------------ */
function EraBlock({ era, i, t }: { era: HistoryEra; i: number; t: Texts }) {
const [ref, fill] = useFill<HTMLDivElement>();
const flip = i % 2 === 1;
return (
<section className="border-t border-border py-14 sm:py-16 lg:py-20">
<div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-2 lg:gap-16">
<div className={cn('lg:sticky lg:top-12 lg:self-start', flip && 'lg:order-last')}>
<Reveal effect="rise">
<p className="text-[15px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{era.to ? `${era.from}–${era.to}` : t.from.replace('{y}', String(era.from))}
</p>
<h3 className="mt-3 text-balance text-[30px] font-semibold leading-[1.12] tracking-[-0.02em] text-foreground sm:text-[38px]">{era.title}</h3>
{era.text && <p className="mt-4 max-w-[34rem] text-pretty text-[17px] leading-[1.65] text-muted-foreground">{era.text}</p>}
</Reveal>
<Picture media={era.media} archive={era.archive} aspect="16 / 10" className="mt-8" parallax />
</div>
<div ref={ref} className="relative pl-8 sm:pl-10">
<span aria-hidden className="absolute bottom-2 left-[5px] top-2 w-px bg-border" />
<span aria-hidden className="absolute bottom-2 left-[5px] top-2 w-px origin-top bg-primary transition-transform duration-150 ease-linear motion-reduce:transition-none" style={{ transform: `scaleY(${fill})` }} />
<ol className="space-y-10">
{era.events.map((e, j) => (
<Reveal as="li" key={j} effect="rise" className="relative">
<span aria-hidden className="absolute -left-8 top-[7px] size-[11px] rounded-full border-2 border-primary bg-background sm:-left-10" />
<p className="text-[14px] font-semibold tabular-nums text-foreground">{e.year}</p>
<h4 className="mt-1 text-pretty text-[19px] font-semibold leading-[1.35] text-foreground">{e.title}</h4>
{e.text && <p className="mt-2 text-pretty text-[16px] leading-[1.65] text-muted-foreground">{e.text}</p>}
{e.figure && (
<p className="mt-3 inline-flex items-baseline gap-2 rounded-lg border border-border bg-surface px-3 py-1.5">
<span className="text-[16px] font-semibold tabular-nums text-foreground">{e.figure.value}</span>
<span className="text-[13px] text-muted-foreground">{e.figure.label}</span>
</p>
)}
</Reveal>
))}
</ol>
</div>
</div>
</section>
);
}
function Eras(p: HistoryTimelineProps & { t: Texts }) {
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} />
{(p.eras ?? []).map((era, i) => (
<EraBlock key={i} era={era} i={i} t={p.t} />
))}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* year-counter */
/* ------------------------------------------------------------------ */
function YearCounter(p: HistoryTimelineProps & { t: Texts }) {
const events = p.events ?? [];
const [active, setActive] = useState(0);
const listRef = useRef<HTMLOListElement>(null);
useEffect(() => {
let frame = 0;
const update = () => {
frame = 0;
const items = [...(listRef.current?.children ?? [])] as HTMLElement[];
const line = window.innerHeight * 0.5;
let cur = 0;
items.forEach((el, i) => {
if (el.getBoundingClientRect().top <= line) cur = i;
});
const last = items[items.length - 1];
if (last && window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 && last.getBoundingClientRect().top < window.innerHeight) cur = items.length - 1;
setActive(cur);
};
const on = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', on, { passive: true });
window.addEventListener('resize', on);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', on);
window.removeEventListener('resize', on);
};
}, [events.length]);
const e = events[active];
const first = events[0]?.year ?? 0;
const last = events[events.length - 1]?.year ?? first;
const share = last > first && e ? (e.year - first) / (last - first) : 1;
const digits = String(e?.year ?? '').split('');
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-x-16 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* The year stands still, on phones as a bar at the top */}
<div className="sticky top-0 z-10 -mx-6 border-b border-border bg-[color-mix(in_oklab,var(--background)_90%,transparent)] px-6 py-3 backdrop-blur-md lg:top-16 lg:mx-0 lg:self-start lg:border-0 lg:bg-transparent lg:p-0 lg:backdrop-blur-none">
<p aria-live="polite" className="flex items-baseline gap-4 overflow-hidden lg:block">
<span className="sr-only">{e?.year}</span>
<span aria-hidden className="flex text-[44px] font-semibold leading-none tracking-[-0.045em] text-foreground tabular-nums sm:text-[56px] lg:text-[clamp(7rem,4rem+6vw,11rem)] lg:leading-[0.85]">
{digits.map((d, i) => (
<span key={i} className="relative inline-block h-[1em] overflow-hidden">
<span className="block transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateY(${-Number(d) * 10}%)` }}>
{'0123456789'.split('').map((n) => (
<span key={n} className="block h-[1em]">
{n}
</span>
))}
</span>
</span>
))}
</span>
<span className="min-w-0 truncate text-[15px] font-medium text-muted-foreground lg:mt-6 lg:block lg:text-[17px]">{e?.title}</span>
</p>
<div className="mt-3 h-0.5 overflow-hidden rounded-full bg-border lg:mt-8 lg:max-w-[22rem]" aria-hidden>
<div className="h-full origin-left bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${round(share, 3)})` }} />
</div>
<div className="mt-2 hidden max-w-[22rem] justify-between text-[13px] tabular-nums text-muted-foreground lg:flex" aria-hidden>
<span>{first}</span>
<span>{last}</span>
</div>
</div>
<ol ref={listRef} className="pt-10 lg:pt-0">
{events.map((ev, i) => (
<li key={i} className={cn('border-b border-border py-10 transition-opacity duration-500 first:pt-0 lg:min-h-[30rem] lg:py-14', active === i ? 'opacity-100' : 'opacity-40 motion-reduce:opacity-100')}>
<p className="text-[14px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{ev.year}</p>
<h3 className="mt-2 text-balance text-[24px] font-semibold leading-[1.2] tracking-[-0.015em] text-foreground sm:text-[28px]">{ev.title}</h3>
{ev.text && <p className="mt-3 max-w-[34rem] text-pretty text-[16.5px] leading-[1.65] text-muted-foreground">{ev.text}</p>}
<Picture media={ev.media} archive={ev.archive} aspect="16 / 10" className="mt-6 max-w-[34rem]" />
</li>
))}
</ol>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* track */
/* ------------------------------------------------------------------ */
function Track(p: HistoryTimelineProps & { t: Texts }) {
const { t } = p;
const uid = useId().replace(/:/g, '');
const events = p.events ?? [];
const [i, setI] = useState(0);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const e = events[i];
const go = (n: number) => setI(Math.min(events.length - 1, Math.max(0, n)));
// Keep the chosen year in view in the ruler
useEffect(() => {
const row = rowRef.current;
const b = row?.querySelector<HTMLElement>(`[data-i="${i}"]`);
if (row && b) row.scrollTo({ left: b.offsetLeft - row.clientWidth / 2 + b.offsetWidth / 2, behavior: 'smooth' });
}, [i, rowRef]);
// Drag the ruler with a mouse, touch scrolls natively
const drag = useRef<{ x: number; left: number } | null>(null);
const key = (ev: React.KeyboardEvent) => {
if (ev.key === 'ArrowRight') {
ev.preventDefault();
go(i + 1);
}
if (ev.key === 'ArrowLeft') {
ev.preventDefault();
go(i - 1);
}
if (ev.key === 'Home') go(0);
if (ev.key === 'End') go(events.length - 1);
};
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} />
{e && (
<div key={i} id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-y${i}`} className="grid grid-cols-[minmax(0,1fr)] items-center gap-8 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-14">
<Picture media={e.media} archive={e.archive} aspect="16 / 10" className="kit-rise" />
<div className="kit-rise" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
<p className="text-[64px] font-semibold leading-none tracking-[-0.045em] text-foreground tabular-nums sm:text-[88px]">{e.year}</p>
<h3 className="mt-4 text-balance text-[24px] font-semibold leading-[1.2] tracking-[-0.015em] text-foreground sm:text-[28px]">{e.title}</h3>
{e.text && <p className="mt-3 text-pretty text-[16.5px] leading-[1.65] text-muted-foreground">{e.text}</p>}
{e.figure && (
<p className="mt-5 flex items-baseline gap-2">
<span className="text-[26px] font-semibold tabular-nums text-foreground">{e.figure.value}</span>
<span className="text-[14px] text-muted-foreground">{e.figure.label}</span>
</p>
)}
</div>
</div>
)}
<div className="mt-12 flex items-center gap-3 sm:mt-14">
<button type="button" onClick={() => go(i - 1)} disabled={i === 0} aria-label={t.previous} className="grid size-11 shrink-0 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>
<div
ref={rowRef}
onPointerDown={(ev) => {
if (ev.pointerType !== 'mouse' || !rowRef.current) return;
drag.current = { x: ev.clientX, left: rowRef.current.scrollLeft };
}}
onPointerMove={(ev) => {
if (!drag.current || !rowRef.current) return;
rowRef.current.scrollLeft = drag.current.left - (ev.clientX - drag.current.x);
}}
onPointerUp={() => (drag.current = null)}
onPointerLeave={() => (drag.current = null)}
className={cn('kit-no-scrollbar relative min-w-0 flex-1 cursor-grab overflow-x-auto active:cursor-grabbing', fade)}
>
<div role="tablist" aria-label={t.timeline} onKeyDown={key} className="relative flex w-max items-end gap-0 px-[40%] pb-1 pt-2">
<span aria-hidden className="absolute inset-x-0 bottom-[22px] h-px bg-border" />
{events.map((ev, j) => {
const gap = j > 0 ? Math.min(160, Math.max(56, (ev.year - events[j - 1].year) * 9)) : 0;
return (
<button
key={j}
type="button"
role="tab"
id={`${uid}-y${j}`}
data-i={j}
aria-selected={i === j}
aria-controls={`${uid}-panel`}
tabIndex={i === j ? 0 : -1}
onClick={() => go(j)}
className="group/y relative flex shrink-0 flex-col items-center gap-2 px-2 pb-0"
style={{ marginLeft: gap }}
>
<span className={cn('text-[14px] font-semibold tabular-nums transition-[color,transform] duration-300', i === j ? '-translate-y-0.5 text-foreground' : 'text-muted-foreground group-hover/y:text-foreground')}>{ev.year}</span>
<span className="relative grid h-11 w-6 place-items-center">
<span className={cn('block rounded-full transition-[width,height,background-color] duration-300', i === j ? 'size-3.5 bg-primary shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_22%,transparent)]' : 'size-2 bg-border-strong group-hover/y:bg-foreground')} />
</span>
</button>
);
})}
</div>
</div>
<button type="button" onClick={() => go(i + 1)} disabled={i === events.length - 1} aria-label={t.next} className="grid size-11 shrink-0 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>
<p className="mt-3 text-center text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
{t.of.replace('{a}', String(i + 1)).replace('{b}', String(events.length))}
</p>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* decades */
/* ------------------------------------------------------------------ */
function Decades(p: HistoryTimelineProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const events = p.events ?? [];
const groups = new Map<number, HistoryEvent[]>();
for (const e of events) {
const d = Math.floor(e.year / 10) * 10;
groups.set(d, [...(groups.get(d) ?? []), e]);
}
const list = [...groups.entries()];
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} />
<Stagger step={70} className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 lg:grid-cols-4">
{list.map(([d, evs]) => {
const lead = evs.find((x) => x.media) ?? evs[0];
return (
<article key={d} className="group/d flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow,border-color] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)]">
<div className="relative overflow-hidden">
<div className="transition-transform duration-700 ease-[var(--ease-kit)] group-hover/d:scale-[1.04]">
<Picture media={lead.media} archive={lead.archive} aspect="16 / 10" className="rounded-none" />
</div>
<p className="absolute bottom-3 left-4 rounded-md bg-[color-mix(in_oklab,var(--background)_82%,transparent)] px-2.5 py-1 text-[13px] font-semibold text-foreground backdrop-blur">
{t.decade.replace('{d}', locale === 'de' ? String(d) : String(d).slice(-2) === '00' ? String(d) : `’${String(d).slice(-2)}`)}
</p>
</div>
<div className="flex flex-1 flex-col p-5 sm:p-6">
<p className="text-[44px] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">
<NumberTicker value={d} from={d - 9} duration={1100} format={{ useGrouping: false }} />
</p>
<ul className="mt-5 space-y-3.5">
{evs.map((e, j) => (
<li key={j} className="grid grid-cols-[3rem_minmax(0,1fr)] gap-3">
<span className="pt-px text-[13.5px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{e.year}</span>
<span>
<span className="block text-pretty text-[15.5px] font-semibold leading-[1.4] text-foreground">{e.title}</span>
{e.text && <span className="mt-1 block text-pretty text-[14px] leading-[1.55] text-muted-foreground">{e.text}</span>}
</span>
</li>
))}
</ul>
</div>
</article>
);
})}
</Stagger>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* anniversary */
/* ------------------------------------------------------------------ */
function Anniversary(p: HistoryTimelineProps & { t: Texts }) {
const { t } = p;
const events = p.events ?? [];
const founded = p.founded ?? events[0]?.year ?? 0;
const age = (p.now ?? events[events.length - 1]?.year ?? founded) - founded;
const [ref, fill] = useFill<HTMLDivElement>();
return (
<section className={cn('kit-glow relative overflow-x-clip py-16 sm:py-20 lg:py-28', p.className)}>
<div className="kit-container relative">
<div className="grid grid-cols-[minmax(0,1fr)] items-end gap-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-16">
<div>
{p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
<p className="kit-rise flex items-end gap-4" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
<span className="kit-gradient-text text-[clamp(7rem,3rem+14vw,15rem)] font-semibold leading-[0.8] tracking-[-0.06em] tabular-nums">
<NumberTicker value={age} duration={1600} />
</span>
<span className="pb-[0.6em] text-[22px] font-semibold leading-tight text-foreground sm:text-[28px]">
{t.years}
<span className="block text-[15px] font-medium text-muted-foreground sm:text-[17px]">{t.since.replace('{y}', String(founded))}</span>
</span>
</p>
{p.title && <h2 className="kit-rise mt-8 max-w-[34rem] text-balance text-[28px] font-semibold leading-[1.15] tracking-[-0.02em] text-foreground sm:text-[36px]" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.title}</h2>}
{p.description && <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '160ms' } as React.CSSProperties}>{p.description}</p>}
</div>
{p.quote && (
<Reveal effect="rise" delay={150} className="kit-card relative p-6 sm:p-8">
<Icon name="quote" size={26} className="text-primary" />
<blockquote className="mt-4 text-pretty text-[19px] font-medium leading-[1.5] tracking-[-0.01em] text-foreground sm:text-[21px]">{p.quote.text}</blockquote>
<div className="mt-6 flex items-center gap-3">
{p.quote.media && (
<span data-clip-ok className="size-12 shrink-0 overflow-hidden rounded-full border border-border grayscale-[0.9] sepia-[0.15] [&>*]:size-full">
{p.quote.media}
</span>
)}
<p className="text-[14px] leading-snug">
<span className="block font-semibold text-foreground">{p.quote.author}</span>
{p.quote.role && <span className="text-muted-foreground">{p.quote.role}</span>}
</p>
</div>
</Reveal>
)}
</div>
<div ref={ref} className="relative mt-16 sm:mt-20">
<h3 className="sr-only">{t.milestones}</h3>
<span aria-hidden className="absolute left-0 right-0 top-[7px] hidden h-px bg-border lg:block" />
<span aria-hidden className="absolute left-0 right-0 top-[7px] hidden h-px origin-left bg-primary transition-transform duration-150 ease-linear motion-reduce:transition-none lg:block" style={{ transform: `scaleX(${round(Math.min(1, fill * 1.6), 3)})` }} />
<span aria-hidden className="absolute bottom-0 left-[7px] top-0 w-px bg-border lg:hidden" />
<span aria-hidden className="absolute bottom-0 left-[7px] top-0 w-px origin-top bg-primary transition-transform duration-150 ease-linear motion-reduce:transition-none lg:hidden" style={{ transform: `scaleY(${round(Math.min(1, fill * 1.2), 3)})` }} />
<ol className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-6 lg:gap-6">
{events.map((e, i) => (
<Reveal as="li" key={i} effect="rise" delay={i * 70} className="relative pl-8 lg:pl-0 lg:pt-8">
<span aria-hidden className="absolute left-0 top-0.5 size-[15px] rounded-full border-2 border-primary bg-background lg:top-0" />
<p className="text-[15px] font-semibold tabular-nums text-foreground">{e.year}</p>
<p className="mt-1.5 text-pretty text-[15px] leading-[1.5] text-muted-foreground">{e.title}</p>
</Reveal>
))}
</ol>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export function HistoryTimeline(props: HistoryTimelineProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'eras' } = props;
if (variant === 'year-counter') return <YearCounter {...props} t={t} />;
if (variant === 'track') return <Track {...props} t={t} />;
if (variant === 'decades') return <Decades {...props} t={t} />;
if (variant === 'anniversary') return <Anniversary {...props} t={t} />;
return <Eras {...props} t={t} />;
}Frequent questions
How do I install History timeline?
With aiw add @ai-website/history-timeline. The command puts components/ui/history-timeline.tsx into your project, the code is yours from then on. It brings along icon, number-ticker, reveal, motion and section. It needs no further npm packages.
Which variants does History timeline have?
5 variants, Eras, Year counter, Year ruler, Decades and Anniversary. You pick one with the variant prop, for example variant="year-counter".
Can I translate the texts of History timeline?
Yes. History timeline carries 9 texts of its own in English and German, such as “years”, “Previous year” and “Next year”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with History timeline?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find History timeline with aiw_components and install it with aiw_add @ai-website/history-timeline. Name the variant in your prompt, for example variant="eras", and the assistant writes your content straight into the props.
Can I adjust History timeline without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching HistoryTimeline code to copy.