Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
  1. Rolled back to 4019Ada Lovelace · 14:34Health check failed in eu-west-2 after the connection pool saturated.
  2. Health check failedSystem · 14:32
  3. Deployed 4021 to productionAda Lovelace · 14:28
  4. Build succeededSystem · 14:26
  5. 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.

  1. Build
  2. Test
  3. DeployCurrent step
  4. Verify

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.

  1. Rolled back to 4019Ada Lovelace · 14:34
  2. Health check failedSystem · 14:32
  3. Deployed 4021 to productionAda Lovelace · 14:28
  4. Build succeededSystem · 14:26
  5. Pushed 4021ab9 to mainGrace Hopper · 14:24

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.

Newest firstMonitoring
  1. Rolled back to 4019Ada Lovelace · 14:34
  2. Health check failedSystem · 14:32
  3. Deployed 4021 to productionAda Lovelace · 14:28
Oldest firstA story
  1. Pushed 4021ab9 to mainGrace Hopper · 14:24
  2. Build succeededSystem · 14:26
  3. Deployed 4021 to productionAda Lovelace · 14:28

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.

  1. Rolled back to 4019Ada Lovelace · 14:34Health check failed in eu-west-2 after the connection pool saturated.
  2. Health check failedSystem · 14:32

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.

Neutral
Success
Failure
3
Current
Done step
4
Future step
AL
With avatar
Connector
Today
Group header

Anatomy

Every part, every measurement, and the reason it is that number.

  1. Rolled back to 4019Ada Lovelace · 14:34Health check failed in eu-west-2 after the connection pool saturated.
  2. Health check failedSystem · 14:32
  3. 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.

  1. 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.

  2. 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.

  3. Gutter12px marker to text

    Fixed regardless of marker content, so an icon row and an avatar row share one text edge.

  4. 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.

  5. Title13px, --ds-fg

    Past tense and specific: "Rolled back to 4019", not "Rollback". The timeline is a record of what happened.

  6. MetadataActor · time

    Who and when, in that order — the actor is what people scan for in an audit trail.

  7. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-3Marker to text gutter
--space-5Gap between events

Radius

TokenValueUsed for
full—Markers

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusIconLabel gapMax widthWhen to use
Marker23px——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 10px8px———Inside the event block, never as a second column.
Measure—————40remAbout 70 characters. A timeline stretched wide leaves the markers marooned from the text.

Do

<ol><li>…</li></ol>
Use an ordered listThe order is the information. An ol announces "list, 12 items" and preserves the sequence; a stack of divs conveys neither.
  1. Rolled back to 4019Ada Lovelace · 14:34
  2. Health check failedSystem · 14:32
Stop the connector at the last eventA line continuing past the final marker says there is more below. That is a factual claim, and if it is false the user keeps scrolling.
<time datetime="2026-07-21T14:28:00Z"
  title="21 July 2026, 14:28 UTC">4 minutes ago</time>
Give both relative and absolute times"4 minutes ago" is what the user wants at a glance; the absolute time is what they need when comparing against a log or a screenshot.
Put the outcome in the markerA failed event should be visible while scanning, before any text is read. The marker is the only element with a consistent position for that.

Don't

  1. Rolled back to 4019Ada Lovelace · 14:34
  2. Health check failedSystem · 14:32
Do not draw the connector past the endIt claims there are more events below. The user scrolls, finds nothing, and trusts the component slightly less next time.
DeployedFailedRolled back
Do not alternate sidesThe zig-zag layout doubles the eye’s travel, breaks completely below about 600px, and makes the reading order ambiguous for assistive tech.
2 hours ago — Rolled back3 hours ago — Deployed1 hour ago — Health check failed
Do not leave the order ambiguousRelative timestamps alone do not say which way the list runs. A user reading a rollback before the failure that caused it draws the wrong conclusion.
  1. BillingSettings
  2. MembersSettings
Do not use one for three unrelated itemsThe connector claims a sequence. Between three things that merely happen to be listed together, that claim is simply wrong.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA1.4.1Use of ColorA1.4.11Non-text ContrastAA2.4.3Focus OrderA

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

TabReaches only genuinely interactive events. A read-only timeline has no tab stops at all.
EnterOpens an event that links to a detail view.
TabReaches 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.
AttributeApplied toNotes
<ol> / <li>The timelineThe order is the information. An ordered list conveys both sequence and count.
<time datetime>Each timestampMachine-readable, and it lets assistive tech and translation tools handle the value properly.
aria-hiddenMarkers and connectorsDecoration. The outcome must also be in the text, or a colour-only marker conveys nothing.
aria-current="step"The active step in a stepperThe only way "you are here" reaches a screen-reader user.
aria-live="polite"A live feedAnnounces new events as they arrive. Throttle it — a busy feed will otherwise talk continuously.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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

PropTypeDefaultDescription
title*ReactNode—Past tense and specific: "Rolled back to 4019", not "Rollback".
metaReactNode—Actor then time, in that order — the actor is what people scan an audit trail for.
iconReactNode—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.
detailReactNode—An inset panel under the title. Inside the event block, never a second column.
hrefstring—Makes the whole event a link to its detail view.

Notes

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.