Timeline for React
Events in order: activity feeds, histories, order tracking and changelogs.
One of 4 components in Lists, trees and timelines
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
Details
What Timeline brings along, read from the registry and the source.
- Category
- Lists, trees and timelines
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TimelineProps interface from components/ui/timeline.tsx, with the comments from the code.
itemsrequiredTimelineItem[]variantoptionalTimelineVariantdefaultalternatehorizontalcompact
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.
labeloptionalstringAccessible 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
| Key | English | German |
|---|---|---|
| done | English Done | German Erledigt |
| current | English In progress | German Läuft |
| upcoming | English Upcoming | German 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
'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.