Timeline
Ordered events on an axis — activity feeds, audit trails, release histories and step progress.
Also called Activity Feed, Event Log, History, Stepper, Step Indicator — in this system all of them are Timeline.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
- Rolled back to 4019Ada Lovelace · 14:34Health check failed in eu-west-2 after the connection pool saturated.
- Health check failedSystem · 14:32
- Deployed 4021 to productionAda Lovelace · 14:28
- Build succeededSystem · 14:26
- Pushed 4021ab9 to mainGrace Hopper · 14:24
A stepper is a horizontal timeline
The same anatomy rotated: markers, connectors and labels. Completed steps fill, the current one is ringed, and future steps stay outlined.
Markers carry the outcome
The marker is where the status lives, so a failure is visible while scanning without reading a single line of text.
Pick an order and state it
Newest-first for monitoring, oldest-first for a story. Ambiguity is the failure mode — relative timestamps alone do not reveal which way the list runs.
Detail belongs in the event, not beside it
An inset panel under the title keeps the explanation attached to the event that produced it. A second column would break the single axis.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
- Rolled back to 4019Ada Lovelace · 14:34Health check failed in eu-west-2 after the connection pool saturated.
- Health check failedSystem · 14:32
- Deployed 4021 to productionAda Lovelace · 14:28
A marker per event carrying its outcome, a connector that stops at the last one, and a title with metadata beneath it.
- Marker23px, 13px icon
Odd-numbered so the 1px connector lands exactly on its centre. At 24px the line sits half a pixel off and looks bent.
- Connector1px, behind the marker
Drawn behind so it never crosses the marker’s fill. It stops at the last event — a line running past the end implies more to come.
- Gutter12px marker to text
Fixed regardless of marker content, so an icon row and an avatar row share one text edge.
- Event gap20px (12px compact)
Enough that events read as separate; tight enough that the connector still reads as continuous rather than as a series of dashes.
- Title13px, --ds-fg
Past tense and specific: "Rolled back to 4019", not "Rollback". The timeline is a record of what happened.
- MetadataActor · time
Who and when, in that order — the actor is what people scan for in an audit trail.
- Detail panelInset surface under the title
Optional. Inside the event block, so the explanation stays attached to the event rather than becoming a second column.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-border-subtle | — | The connector |
| --ds-surface | — | Marker fill for a neutral event |
| --ds-success-subtle | — | Marker fill for a success |
| --ds-danger-subtle | — | Marker fill for a failure |
| --ds-accent | — | A completed step and the connector behind it |
| --ds-surface-inset | — | The detail panel |
| --ds-fg | — | Event titles |
| --ds-fg-muted | — | Actor and timestamp |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-3 | Marker to text gutter | |
| --space-5 | Gap between events |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Markers |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Label gap | Max width | When to use |
|---|---|---|---|---|---|---|---|
| Marker | 23px | — | — | 13px | — | — | Odd-numbered, so a 1px connector lands on its exact centre. |
| Compact | — | — | — | — | 12px between events | — | Long feeds and audit logs, where density beats breathing room. |
| Default | — | — | — | — | 20px between events | — | The default. Enough separation to read events as distinct. |
| Gutter | — | — | — | — | 12px | — | Marker to text. Fixed regardless of what the marker contains. |
| Detail panel | — | 8px 10px | 8px | — | — | — | Inside the event block, never as a second column. |
| Measure | — | — | — | — | — | 40rem | About 70 characters. A timeline stretched wide leaves the markers marooned from the text. |
<ol><li>…</li></ol>- Rolled back to 4019Ada Lovelace · 14:34
- Health check failedSystem · 14:32
<time datetime="2026-07-21T14:28:00Z"
title="21 July 2026, 14:28 UTC">4 minutes ago</time>- Rolled back to 4019Ada Lovelace · 14:34
- Health check failedSystem · 14:32
- BillingSettings
- MembersSettings
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Marker tones must not rely on colour alone. Each carries its own icon — a tick, a warning triangle — so the outcome survives greyscale.
- The connector is decorative when the ordered list already conveys sequence, and may sit at subtle contrast.
- Timestamps are content and owe 4.5:1, even though they read as secondary.
- In a stepper, the completed and future states must differ by fill as well as colour.
Keyboard
| Tab | Reaches only genuinely interactive events. A read-only timeline has no tab stops at all. |
| Enter | Opens an event that links to a detail view. |
| Tab | Reaches a "Load more" control at the end of a paginated feed. |
Screen readers
- Each event should read as one phrase: "Deployed 4021 to production, Ada Lovelace, 14:28".
- State the sort order once at the top: "Newest first". Otherwise the sequence is guessable but not knowable.
- For a live feed, throttle announcements. A deployment log announcing every line is unusable.
Focus & touch
- A read-only timeline has no focus stops. When events are links, focus order follows the visual order, which is why alternating sides is a problem — the DOM order and the visual order cannot both be right.
- Timelines work well on mobile precisely because they are a single column — the layout that fails there is the alternating one. Keep the gutter narrow, let detail panels wrap, and make whole events tappable rather than putting small targets inside them.
| Attribute | Applied to | Notes |
|---|---|---|
| <ol> / <li> | The timeline | The order is the information. An ordered list conveys both sequence and count. |
| <time datetime> | Each timestamp | Machine-readable, and it lets assistive tech and translation tools handle the value properly. |
| aria-hidden | Markers and connectors | Decoration. The outcome must also be in the text, or a colour-only marker conveys nothing. |
| aria-current="step" | The active step in a stepper | The only way "you are here" reaches a screen-reader user. |
| aria-live="polite" | A live feed | Announces new events as they arrive. Throttle it — a busy feed will otherwise talk continuously. |
Example usage
1import { Timeline, TimelineItem } from '@/ui/Display'23<Timeline aria-label="Deployment history">4 {events.map((e) => (5 <TimelineItem6 key={e.id}7 icon={ICONS[e.type]}8 tone={e.failed ? 'danger' : 'success'}9 title={e.title} // past tense, specific10 meta={<>{e.actor} · <RelativeTime value={e.at} /></>}11 detail={e.detail}12 />13 ))}14</Timeline>1516// Both forms: relative for the glance, absolute for comparing against a log.17function RelativeTime({ value }: { value: string }) {18 const d = new Date(value)19 return (20 <time dateTime={value} title={d.toLocaleString(undefined, { timeZoneName: 'short' })}>21 {formatRelative(d)}22 </time>23 )24}2526// The connector stops at the last event. A line past the end claims there27// is more below, and the user scrolls to find nothing.28<li className="relative">29 {!isLast && <span aria-hidden className="ds-timeline__line" />}30 …31</li>3233// Group by day for long feeds — a hundred undifferentiated events is a wall.34const byDay = groupBy(events, (e) => startOfDay(e.at).toISOString())Framework-free HTML
<!-- An ordered list: the sequence IS the content. -->
<ol class="ds-timeline" aria-label="Deployment history, newest first">
<li class="ds-timeline__event">
<!-- Decoration. The outcome must also be in the text. -->
<span class="ds-timeline__line" aria-hidden="true"></span>
<span class="ds-timeline__marker" data-tone="danger" aria-hidden="true">
<svg>…</svg>
</span>
<div class="ds-timeline__body">
<p class="ds-timeline__title">Rolled back to 4019</p>
<p class="ds-timeline__meta">
Ada Lovelace ·
<time datetime="2026-07-21T14:34:00Z"
title="21 July 2026, 14:34 UTC">4 minutes ago</time>
</p>
<p class="ds-timeline__detail">
Health check failed in eu-west-2 after the connection pool saturated.
</p>
</div>
</li>
<!-- No connector on the last event. -->
<li class="ds-timeline__event ds-timeline__event--last">…</li>
</ol>CSS
.ds-timeline { list-style: none; margin: 0; padding: 0; }
.ds-timeline__event {
position: relative;
display: flex;
gap: 12px; /* fixed, so icon and avatar markers
share one text edge */
padding-block-end: 20px;
}
.ds-timeline__marker {
position: relative;
z-index: 1; /* above the line, so it is never crossed */
/* Odd, so the 1px connector lands on its exact centre. At 24px the line
sits half a pixel off and looks bent. */
inline-size: 23px;
block-size: 23px;
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid var(--ds-border);
background: var(--ds-surface);
}
.ds-timeline__marker[data-tone='success'] {
border-color: var(--ds-success-border);
background: var(--ds-success-subtle);
color: var(--ds-success-text);
}
.ds-timeline__marker[data-tone='danger'] {
border-color: var(--ds-danger-border);
background: var(--ds-danger-subtle);
color: var(--ds-danger-text);
}
.ds-timeline__line {
position: absolute;
inset-block: 23px 0;
inset-inline-start: 11px; /* (23 − 1) / 2 */
inline-size: 1px;
background: var(--ds-border-subtle);
}
/* The connector stops here. Anything else claims more events below. */
.ds-timeline__event--last .ds-timeline__line { display: none; }
.ds-timeline__event--last { padding-block-end: 0; }
.ds-timeline__detail {
margin-block-start: 4px;
padding: 8px 10px;
border: 1px solid var(--ds-border-subtle);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
}Component API
Timeline
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label* | string | — | Names the sequence and states the order: "Deployment history, newest first". |
| density | 'compact' | 'default' | 'default' | Compact for long audit logs where density beats breathing room. |
| orientation | 'vertical' | 'horizontal' | 'vertical' | Horizontal is a stepper. Never alternating — the reading order becomes ambiguous. |
TimelineItem
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | ReactNode | — | Past tense and specific: "Rolled back to 4019", not "Rollback". |
| meta | ReactNode | — | Actor then time, in that order — the actor is what people scan an audit trail for. |
| icon | ReactNode | — | 13px inside the marker. Carries the outcome alongside the tone. |
| tone | 'neutral' | 'success' | 'danger' | 'accent' | 'neutral' | Colours the marker. Always paired with an icon so it survives greyscale. |
| detail | ReactNode | — | An inset panel under the title. Inside the event block, never a second column. |
| href | string | — | Makes the whole event a link to its detail view. |
Professional tips
- Group long feeds by day with a sticky header. A hundred undifferentiated events is a wall; "Today", "Yesterday" turns it into chapters.
- Collapse runs of identical events — "12 health checks passed" beats twelve identical rows and makes the exceptions visible.
- Write titles in the past tense with the object named. "Deployed 4021 to production" is a record; "Deploy" is a button label in the wrong place.
- For live feeds, insert new events with no animation at the top and leave the scroll position alone. Auto-scrolling a feed the user is reading is the fastest way to lose them.
- Link each event to its own detail view rather than expanding in place. Expansion moves everything below the event the user just clicked.
Performance
- Paginate or virtualise past a few hundred events. Audit trails grow without limit and a naive render eventually locks the page.
- Format timestamps with a shared Intl formatter rather than constructing one per row.
- Update relative times on an interval, not on every render — one tick per minute is enough and avoids re-rendering the whole feed on unrelated state changes.
- Draw the connector with a pseudo-element rather than an extra node. In a thousand-event feed that is a thousand elements saved.
Common mistakes
- A connector running past the last event, claiming more below.
- Alternating sides, which breaks on mobile and makes the reading order ambiguous.
- Relative timestamps only, so the order and the exact moment are both unknowable.
- Divs instead of an ordered list, conveying neither sequence nor count.
- Colour-only markers, so the outcome disappears in greyscale.
- An even-numbered marker size, leaving the connector visibly off-centre.
- Detail as a second column, breaking the single axis the timeline is built on.
- A live feed announcing every event to screen readers with no throttle.
Real-world recommendations
- Audit trails are the strongest case: the order is legally meaningful, the actor matters, and nobody wants to sort them by anything else.
- Activity feeds go stale fast. Collapsing repetitive events and grouping by day is what keeps them readable past a few dozen items.
- Steppers are timelines that have been rotated, and they inherit the same rules — the connector still claims a sequence, and the current step still needs aria-current.
- The alternating-sides layout appears in almost every design inspiration gallery and almost never in a shipped product. There is a reason for that.