Skip to content
UI Kit
Accent color

Timeline for React

Events in order: activity feeds, histories, order tracking and changelogs.

One of 4 components in Lists, trees and timelines

4 variants

Events in order, four ways

A timeline puts events in order and shows what happened when. This one renders an ordered list with real time elements, formats dates in the language of the page and keeps the line and the markers aligned with the first line of each entry.

Activity is the feed of an application, who did what and when, with avatars and relative times. History tells the story of a company or a project with years and pictures. Tracking shows the steps of an order or a delivery with done, current and upcoming. Changelog lists releases with a version, a date and grouped changes.

Put the newest entry first in feeds and changelogs, and the oldest first in a history. Use relative times such as two hours ago for recent events and full dates for anything older than a week. Keep entries short and link to details, a timeline is an index, not the content.

Variants

Activity

Icons on a line, times on the right, previews under events.

History

A center line with milestones on both sides, images in between.

Tracking

Horizontal steps with done, current and upcoming states.

Changelog

Small markers, dates in a column, versions as badges.

Details

What Timeline brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.0.0
File
components/ui/timeline.tsx
Brings along
utilskiticonreveal
npm packages
None
Languages
English and German, 3 texts of its own
Studio
Adjust in code

Props

The TimelineProps interface from components/ui/timeline.tsx, with the comments from the code.

  • itemsrequired
    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

Built-in texts

Timeline writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 3 texts
doneEnglish DoneGerman Erledigt
currentEnglish In progressGerman Läuft
upcomingEnglish UpcomingGerman Geplant

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • 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.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 316 lines
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>
  );
}

Frequent questions

How do I install Timeline?

With aiw add @ai-website/timeline. The command puts components/ui/timeline.tsx into your project, the code is yours from then on. It brings along icon and reveal. It needs no further npm packages.

Which variants does Timeline have?

4 variants, Activity, History, Tracking and Changelog. You pick one with the variant prop, for example variant="horizontal".

Can I translate the texts of Timeline?

Yes. Timeline carries 3 texts of its own in English and German, such as “Done”, “In progress” and “Upcoming”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Timeline?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Timeline with aiw_components and install it with aiw_add @ai-website/timeline. Name the variant in your prompt, for example variant="alternate", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard