Skip to content
UI Kit
Accent color

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

5 variants

Variants

Eras

The history in chapters. Each era with its picture standing beside the events, a line that fills while reading, sides alternate.

Open in Studio

Year counter

A large year stands still and rolls digit by digit to the event being read. On phones it rides along as a bar at the top.

Open in Studio

Year ruler

A ruler with the gaps between the years to drag, swipe or step through with arrows. The chosen year opens above it with picture and figure.

Open in Studio

Decades

One tile per decade with its picture and milestones. The decade counts up once, the picture zooms on hover.

Open in Studio

Anniversary

The years set huge and counting up, the founding quote beside them and a strip of milestones whose line draws itself while scrolling.

Open in Studio

Details

What History timeline brings along, read from the registry and the source.

Category
About
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

Props

The HistoryTimelineProps interface from components/ui/history-timeline.tsx, with the comments from the code.

  • variantoptional
    HistoryTimelineVariant

    erasyear-countertrackdecadesanniversary

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • eventsoptional
    HistoryEvent[]
  • erasoptional
    HistoryEra[]
  • foundedoptional
    number

    anniversary: the founding year, the current year is computed from `now`

  • nowoptional
    number

    anniversary: 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
yearsEnglish yearsGerman Jahre
previousEnglish Previous yearGerman Vorheriges Jahr
nextEnglish Next yearGerman Nächstes Jahr
timelineEnglish YearsGerman Jahre
sinceEnglish since {y}German seit {y}
fromEnglish Since {y}German Seit {y}
ofEnglish {a} of {b}German {a} von {b}
milestonesEnglish MilestonesGerman Meilensteine
decadeEnglish The {d}sGerman 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
components/ui/history-timeline.tsx
'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.

Often searched

Jump to

Copied to the clipboard