Zum Inhalt springen
UI Kit
Accent

Firmengeschichte

Die Geschichte eines Unternehmens, groß erzählt. Epochen mit einer Linie, die sich füllt, eine Jahreszahl, die mitzählt, ein Jahreslineal zum Ziehen, Jahrzehnt-Kacheln und das Jubiläum mit riesiger Zahl. Alte Bilder erscheinen auf Wunsch als Archivabzug.

Eine von 2 Components in About

5 Variants

Variants

Epochen

Die Geschichte in Kapiteln. Jede Epoche mit ihrem Bild neben den Ereignissen, eine Linie, die sich beim Lesen füllt, die Seiten wechseln.

Im Studio öffnen

Jahreszähler

Eine große Jahreszahl bleibt stehen und rollt Ziffer für Ziffer zum Ereignis, das gerade gelesen wird. Auf dem Telefon fährt sie als Leiste oben mit.

Im Studio öffnen

Jahreslineal

Ein Lineal mit den echten Abständen zwischen den Jahren, zum Ziehen, Wischen oder mit Pfeiltasten. Das gewählte Jahr öffnet sich darüber mit Bild und Zahl.

Im Studio öffnen

Jahrzehnte

Eine Kachel je Jahrzehnt mit Bild und Meilensteinen. Das Jahrzehnt zählt einmal hoch, das Bild zoomt beim Darüberfahren.

Im Studio öffnen

Jubiläum

Die Jahre riesig und hochzählend, das Zitat der Gründerin daneben und eine Leiste der Meilensteine, deren Linie sich beim Scrollen zeichnet.

Im Studio öffnen

Details

Was Firmengeschichte mitbringt, aus Registry und Quellcode gelesen.

Kategorie
About
Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/history-timeline.tsx
Bringt mit
utilskiticonnumber-tickerrevealmotionsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 9 eigene Texte

Props

Die Schnittstelle HistoryTimelineProps aus components/ui/history-timeline.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Firmengeschichte selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 9 Texte anzeigen
yearsEnglisch yearsDeutsch Jahre
previousEnglisch Previous yearDeutsch Vorheriges Jahr
nextEnglisch Next yearDeutsch Nächstes Jahr
timelineEnglisch YearsDeutsch Jahre
sinceEnglisch since {y}Deutsch seit {y}
fromEnglisch Since {y}Deutsch Seit {y}
ofEnglisch {a} of {b}Deutsch {a} von {b}
milestonesEnglisch MilestonesDeutsch Meilensteine
decadeEnglisch The {d}sDeutsch Die {d}er

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Firmengeschichte reagiert selbst auf die Tasten Pfeil links, Pfeil rechts, Pos1 und Ende.
  • Setzt aria-controls, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • 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. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 549 Zeilen
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} />;
}

Häufige Fragen

Wie installiere ich Firmengeschichte?

Mit aiw add @ai-website/history-timeline. Der Befehl legt die Datei components/ui/history-timeline.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, number-ticker, reveal, motion und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Firmengeschichte?

5 Varianten, Epochen, Jahreszähler, Jahreslineal, Jahrzehnte und Jubiläum. Du wählst sie mit der Prop variant, zum Beispiel variant="year-counter".

Kann ich die Texte von Firmengeschichte übersetzen?

Ja. Firmengeschichte bringt 9 eigene Texte auf Deutsch und Englisch mit, etwa „Jahre“, „Vorheriges Jahr“ und „Nächstes Jahr“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Firmengeschichte einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Firmengeschichte mit aiw_components und installieren sie mit aiw_add @ai-website/history-timeline. Nenn im Prompt die Variante, etwa variant="eras", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Firmengeschichte ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden HistoryTimeline-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert