Zum Inhalt springen
UI Kit
Accent

Timeline für React

Ereignisse der Reihe nach: Verläufe, Geschichte, Sendungsverfolgung und Änderungen.

Eine von 4 Components in Listen, Bäume und Timelines

4 Variants

Ereignisse in Reihenfolge, vierfach

Eine Timeline bringt Ereignisse in Reihenfolge und zeigt, was wann geschah. Diese rendert eine geordnete Liste mit echten time-Elementen, formatiert Daten in der Sprache der Seite und hält Linie und Markierungen auf Höhe der ersten Zeile jedes Eintrags.

„Aktivität“ ist der Feed einer Anwendung, wer was wann getan hat, mit Avataren und relativen Zeiten. „Geschichte“ erzählt die Geschichte einer Firma oder eines Projekts mit Jahren und Bildern. „Sendungsverfolgung“ zeigt die Schritte einer Bestellung oder Lieferung mit erledigt, aktuell und kommend. „Änderungen“ listet Releases mit Version, Datum und gruppierten Änderungen.

Stell in Feeds und Changelogs den neuesten Eintrag nach oben, in einer Geschichte den ältesten. Nutze relative Zeiten wie vor zwei Stunden für Frisches und volle Daten für alles, was älter als eine Woche ist. Halte Einträge kurz und verlinke Details, eine Timeline ist ein Index, nicht der Inhalt.

Variants

Aktivität

Icons auf einer Linie, Zeiten rechts, Vorschauen unter den Ereignissen.

Geschichte

Eine Mittellinie mit Meilensteinen auf beiden Seiten, Bilder dazwischen.

Sendungsverfolgung

Waagerechte Schritte mit erledigt, aktuell und geplant.

Änderungen

Kleine Marker, Daten in einer Spalte, Versionen als Abzeichen.

Details

Was Zeitleiste mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.0.0
Datei
components/ui/timeline.tsx
Bringt mit
utilskiticonreveal
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 3 eigene Texte
Studio
Im Code anpassen

Props

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

  • itemsPflicht
    TimelineItem[]
  • variantoptional
    TimelineVariant

    defaultalternatehorizontalcompact

    default: line on the left with markers. alternate: center line, items left and right (left line on phones). horizontal: a row from lg on, vertical below. compact: dense log with time column.

  • labeloptional
    string

    Accessible name of the list

Eingebaute Texte

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

Alle 3 Texte anzeigen
doneEnglisch DoneDeutsch Erledigt
currentEnglisch In progressDeutsch Läuft
upcomingEnglisch UpcomingDeutsch Geplant

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • 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.

Quellcode

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

Datei anzeigen, 316 Zeilen
components/ui/timeline.tsx
'use client';

import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';

export type TimelineVariant = 'default' | 'alternate' | 'horizontal' | 'compact';
export type TimelineStatus = 'done' | 'current' | 'upcoming';

export interface TimelineItem {
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Visible time or date, e.g. "Today, 09:12" or "2019" */
  time?: React.ReactNode;
  /** Machine readable date for <time>, e.g. "2026-10-19T09:12" */
  dateTime?: string;
  icon?: IconName;
  /** done and current fill the line up to that point */
  status?: TimelineStatus;
  /** Extra content under the text: an image, a quote, buttons */
  media?: React.ReactNode;
  /** Small label next to the title, e.g. a badge */
  meta?: React.ReactNode;
}

export interface TimelineProps {
  items: TimelineItem[];
  /**
   * default: line on the left with markers. alternate: center line, items left and right
   * (left line on phones). horizontal: a row from lg on, vertical below. compact: dense log with time column.
   */
  variant?: TimelineVariant;
  /** Accessible name of the list */
  label?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: { done: 'Done', current: 'In progress', upcoming: 'Upcoming' },
  de: { done: 'Erledigt', current: 'Läuft', upcoming: 'Geplant' },
};

/*
 * The line fills as you read: each segment follows the scroll position, each
 * point lights up when the line reaches it, each entry rises when it comes into
 * view. The horizontal tracker fills step by step once it is in view.
 * The server renders everything finished, the script only dims what is still ahead.
 */
const STYLES =
  '.kitfx-tl-mark{transition:opacity .45s var(--ease-kit),scale .55s cubic-bezier(.34,1.56,.64,1),filter .45s var(--ease-kit)}' +
  '.kitfx-tl-item[data-lit="0"] .kitfx-tl-mark{opacity:.45;scale:.82;filter:grayscale(1)}' +
  '.kitfx-tl-item[data-lit="1"] .kitfx-tl-mark{animation:kitfx-tl-glow 1s var(--ease-kit)}' +
  '@keyframes kitfx-tl-glow{0%{box-shadow:0 0 0 0 color-mix(in oklab,var(--primary) 45%,transparent)}100%{box-shadow:0 0 0 12px color-mix(in oklab,var(--primary) 0%,transparent)}}' +
  '.kitfx-tl-body{transition:opacity .7s var(--ease-kit),translate .7s var(--ease-kit)}' +
  '.kitfx-tl-item[data-seen="0"] .kitfx-tl-body{opacity:0;translate:0 12px}' +
  '.kitfx-tl-fill{transform-origin:top}' +
  '.kitfx-tl-step .kitfx-tl-fill{transition:transform .5s cubic-bezier(.45,0,.25,1)}' +
  '.kitfx-tl-step[data-fill="0"] .kitfx-tl-fill{transform:scaleY(0)}' +
  '@media (min-width:64rem){.kitfx-tl-row .kitfx-tl-fill{transform-origin:left}.kitfx-tl-step[data-fill="0"] .kitfx-tl-row .kitfx-tl-fill,.kitfx-tl-row.kitfx-tl-step[data-fill="0"] .kitfx-tl-fill{transform:scaleX(0)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-tl-mark,.kitfx-tl-body,.kitfx-tl-step .kitfx-tl-fill{transition:none}.kitfx-tl-item .kitfx-tl-mark{animation:none}}';

const clamp01 = (v: number) => Math.min(1, Math.max(0, v));

/** Follows the scroll: fills segments, lights points, shows entries. Writes to the DOM directly, no React render per frame. */
function useScrollTrack(ref: React.RefObject<HTMLOListElement | null>) {
  useEffect(() => {
    const list = ref.current;
    if (!list || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const vh = window.innerHeight;
      // The reading line sits at 62 % of the screen and moves down near the end of the page, so the last entries light up too
      const remaining = document.documentElement.scrollHeight - (window.scrollY + vh);
      const anchor = vh * (0.62 + 0.4 * clamp01(1 - remaining / (vh * 0.5)));
      list.querySelectorAll<HTMLElement>('[data-tl-item]').forEach((item) => {
        const mark = item.querySelector<HTMLElement>('[data-tl-mark]');
        const fill = item.querySelector<HTMLElement>('[data-tl-fill]');
        const top = item.getBoundingClientRect().top;
        item.dataset.seen = top < vh * 0.94 ? '1' : '0';
        if (mark) {
          const r = mark.getBoundingClientRect();
          item.dataset.lit = r.top + r.height / 2 < anchor ? '1' : '0';
        }
        if (fill?.parentElement) {
          const s = fill.parentElement.getBoundingClientRect();
          fill.style.transform = `scaleY(${clamp01((anchor - s.top) / (s.height || 1)).toFixed(3)})`;
        }
      });
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, [ref]);
}

/** For the tracker: once in view, the steps light up and the line fills from one to the next */
function useStepTrack(ref: React.RefObject<HTMLOListElement | null>) {
  useEffect(() => {
    const list = ref.current;
    if (!list || prefersReducedMotion()) return;
    if (list.getBoundingClientRect().top < window.innerHeight * 0.85) return;
    const items = Array.from(list.querySelectorAll<HTMLElement>('[data-tl-item]'));
    items.forEach((it) => {
      it.dataset.lit = '0';
      it.dataset.seen = '0';
      it.dataset.fill = '0';
    });
    const timers: number[] = [];
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        io.disconnect();
        items.forEach((it, i) => {
          timers.push(window.setTimeout(() => (it.dataset.seen = '1'), i * 90));
          timers.push(window.setTimeout(() => (it.dataset.lit = '1'), 200 + i * 520));
          timers.push(window.setTimeout(() => (it.dataset.fill = '1'), 260 + i * 520));
        });
      },
      { rootMargin: '0px 0px -15% 0px' },
    );
    io.observe(list);
    return () => {
      io.disconnect();
      timers.forEach((t) => window.clearTimeout(t));
    };
  }, [ref]);
}

function Marker({ item, small }: { item: TimelineItem; small?: boolean }) {
  const st = item.status;
  if (small) {
    return (
      <span
        data-tl-mark
        className={cn(
          'kitfx-tl-mark relative z-[1] block size-2.5 rounded-full',
          st === 'upcoming' ? 'bg-background shadow-[inset_0_0_0_1.5px_var(--border-strong)]' : st === 'current' ? 'bg-primary shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_18%,transparent)]' : 'bg-[color-mix(in_oklab,var(--foreground)_35%,var(--background))]',
        )}
      />
    );
  }
  return (
    <span
      data-tl-mark
      className={cn(
        'kitfx-tl-mark relative z-[1] grid size-8 place-items-center rounded-full',
        st === 'done' && 'bg-primary text-primary-foreground',
        st === 'current' && 'bg-background text-foreground shadow-[inset_0_0_0_1.5px_var(--primary),0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)]',
        (st === 'upcoming' || !st) && 'border border-border-strong bg-card text-muted-foreground shadow-[var(--shadow-sm)]',
      )}
    >
      {st === 'done' && !item.icon ? <Icon name="check" size={15} strokeWidth={2.2} /> : item.icon ? <Icon name={item.icon} size={15} /> : <span className={cn('size-2 rounded-full', st === 'current' ? 'bg-primary' : 'bg-border-strong')} />}
    </span>
  );
}

function When({ item, className }: { item: TimelineItem; className?: string }) {
  if (!item.time) return null;
  return item.dateTime ? (
    <time dateTime={item.dateTime} className={cn('text-[13px] tabular-nums text-muted-foreground', className)}>
      {item.time}
    </time>
  ) : (
    <span className={cn('text-[13px] tabular-nums text-muted-foreground', className)}>{item.time}</span>
  );
}

function Title({ item, className }: { item: TimelineItem; className?: string }) {
  return (
    <div className={cn('flex flex-wrap items-center gap-x-2.5 gap-y-1', className)}>
      <h3 className={cn('text-[15px] font-semibold tracking-[-0.01em]', item.status === 'upcoming' ? 'text-muted-foreground' : 'text-foreground')}>{item.title}</h3>
      {item.meta}
    </div>
  );
}

/**
 * Line from the bottom of one marker to the top of the next. Done steps and entries without a status
 * get a fill that follows the reader, the rest of the way stays a quiet line.
 */
function Line({ item, className }: { item: TimelineItem; className: string }) {
  const fills = item.status === 'done' || !item.status;
  return (
    <span aria-hidden className={cn('overflow-hidden bg-border-strong', className)}>
      {fills && <span data-tl-fill className={cn('kitfx-tl-fill block size-full', item.status === 'done' ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]')} />}
    </span>
  );
}

/** Events in order: activity feeds, histories, order tracking and changelogs. */
export function Timeline({ items, variant = 'default', label, labels, className }: TimelineProps) {
  const t = useTexts(TEXTS, labels);
  const sr = (item: TimelineItem) => item.status && <span className="sr-only">, {t[item.status]}</span>;
  const last = items.length - 1;
  const ref = useRef<HTMLOListElement>(null);
  const horizontal = variant === 'horizontal';
  // Both hooks run, only the one for this variant does anything
  useScrollTrack(horizontal ? { current: null } : ref);
  useStepTrack(horizontal ? ref : { current: null });
  const style = (
    <style href="kitfx-tl" precedence="default">
      {STYLES}
    </style>
  );

  if (variant === 'compact') {
    return (
      <ol ref={ref} aria-label={label} className={cn('relative', className)}>
        {style}
        {items.map((item, i) => (
          <li key={i} data-tl-item className="kitfx-tl-item relative grid grid-cols-[1rem_1fr] gap-x-4 pb-7 last:pb-0 sm:grid-cols-[7.5rem_1rem_1fr]">
            <When item={item} className="kitfx-tl-body col-start-2 row-start-1 mb-1 sm:col-start-1 sm:mb-0 sm:pt-px sm:text-right" />
            <span className="relative col-start-1 row-span-3 row-start-1 flex justify-center pt-1.5 sm:col-start-2">
              <Marker item={item} small />
              {i < last && <Line item={item} className="absolute bottom-[-1.75rem] left-1/2 top-4 w-px -translate-x-1/2" />}
            </span>
            <div className="kitfx-tl-body col-start-2 min-w-0 sm:col-start-3 sm:row-start-1">
              <Title item={item} className="[&_h3]:text-sm" />
              {item.description && <div className="mt-1 text-pretty text-sm text-muted-foreground">{item.description}</div>}
              {item.media && <div className="mt-3">{item.media}</div>}
              {sr(item)}
            </div>
          </li>
        ))}
      </ol>
    );
  }

  if (horizontal) {
    return (
      <ol ref={ref} aria-label={label} className={cn('relative lg:flex', className)}>
        {style}
        {items.map((item, i) => (
          <li key={i} data-tl-item className="kitfx-tl-item kitfx-tl-step kitfx-tl-row relative flex gap-4 pb-8 last:pb-0 lg:flex-1 lg:flex-col lg:gap-5 lg:pb-0 lg:pr-6">
            <span className="relative flex shrink-0 flex-col items-center lg:flex-row">
              <Marker item={item} />
              {i < last && <Line item={item} className="absolute bottom-[-2rem] left-1/2 top-8 w-px -translate-x-1/2 lg:bottom-auto lg:left-8 lg:right-[-1.5rem] lg:top-1/2 lg:h-px lg:w-auto lg:translate-x-0" />}
            </span>
            <div className="kitfx-tl-body min-w-0 pt-1 lg:pt-0">
              <When item={item} className="mb-1 block" />
              <Title item={item} />
              {item.description && <div className="mt-1.5 text-pretty text-sm text-muted-foreground">{item.description}</div>}
              {item.media && <div className="mt-3">{item.media}</div>}
              {sr(item)}
            </div>
          </li>
        ))}
      </ol>
    );
  }

  if (variant === 'alternate') {
    return (
      <ol ref={ref} aria-label={label} className={cn('relative', className)}>
        {style}
        {items.map((item, i) => {
          const right = i % 2 === 1;
          return (
            <li key={i} data-tl-item className="kitfx-tl-item relative grid grid-cols-[2rem_1fr] gap-x-5 pb-12 last:pb-0 lg:grid-cols-[1fr_2rem_1fr] lg:gap-x-10">
              <span className="relative col-start-1 row-start-1 flex justify-center lg:col-start-2">
                <Marker item={item} />
                {i < last && <Line item={item} className="absolute bottom-[-3rem] left-1/2 top-8 w-px -translate-x-1/2" />}
              </span>
              {item.time && (
                <Reveal effect="fade" className={cn('row-start-1 hidden lg:block', right ? 'lg:col-start-1 lg:text-right' : 'lg:col-start-3')}>
                  <When item={item} className="block text-[1.75rem] font-semibold leading-8 tracking-[-0.03em] text-[color-mix(in_oklab,var(--primary)_70%,var(--muted-foreground))]" />
                </Reveal>
              )}
              <Reveal className={cn('col-start-2 row-start-1 min-w-0 pt-1', right ? 'lg:col-start-3' : 'lg:col-start-1 lg:text-right')}>
                <When item={item} className="mb-1 block font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))] lg:hidden" />
                <Title item={item} className={cn(!right && 'lg:justify-end')} />
                {item.description && <div className={cn('mt-2 text-pretty text-sm leading-relaxed text-muted-foreground', !right && 'lg:ml-auto', 'max-w-[30rem]')}>{item.description}</div>}
                {item.media && <div className={cn('mt-4 max-w-[30rem] overflow-hidden rounded-lg', !right && 'lg:ml-auto')} data-clip-ok>{item.media}</div>}
                {sr(item)}
              </Reveal>
            </li>
          );
        })}
      </ol>
    );
  }

  return (
    <ol ref={ref} aria-label={label} className={cn('relative', className)}>
      {style}
      {items.map((item, i) => (
        <li key={i} data-tl-item className="kitfx-tl-item relative grid grid-cols-[2rem_1fr] gap-x-4 pb-8 last:pb-0">
          <span className="relative flex justify-center">
            <Marker item={item} />
            {i < last && <Line item={item} className="absolute bottom-[-2rem] left-1/2 top-8 w-px -translate-x-1/2" />}
          </span>
          <div className="kitfx-tl-body min-w-0 pt-1">
            <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
              <Title item={item} />
              <When item={item} />
            </div>
            {item.description && <div className="mt-1 text-pretty text-sm text-muted-foreground">{item.description}</div>}
            {item.media && <div className="mt-3">{item.media}</div>}
            {sr(item)}
          </div>
        </li>
      ))}
    </ol>
  );
}

Häufige Fragen

Wie installiere ich Zeitleiste?

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

Welche Varianten hat Zeitleiste?

4 Varianten, Aktivität, Geschichte, Sendungsverfolgung und Änderungen. Du wählst sie mit der Prop variant, zum Beispiel variant="horizontal".

Kann ich die Texte von Zeitleiste übersetzen?

Ja. Zeitleiste bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Erledigt“, „Läuft“ und „Geplant“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Zeitleiste einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert