Data

Timeline

A vertical activity feed of what happened, who did it and when, with optional folded detail.

Built on
<ol><time><details>
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
2.2 kB
Dependencies
React
  1. Build 4821 started
    Priya Raman
  2. Checks passed
  3. Rollout failed
Try its options

Loading its options.

Code
import { Timeline, TimelineItem } from "@/components/equip/timeline";

export default function Demo() {
  return (
    <Timeline label="Deploy activity" style={{ width: 216 }}>
      <TimelineItem title="Build 4821 started" time="14:02" dateTime="2026-03-12T14:02" actor="Priya Raman" />
      <TimelineItem title="Checks passed" time="14:06" dateTime="2026-03-12T14:06" tone="ok" />
      <TimelineItem title="Rollout failed" time="14:09" dateTime="2026-03-12T14:09" tone="danger" />
    </Timeline>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<ol aria-label="Deploy activity" style="width:216px" class="eq-timeline">
  <li class="eq-timeline-item" data-tone="default">
    <span class="eq-timeline-marker" aria-hidden="true">
      <span class="eq-timeline-dot"></span>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Build 4821 started</span>
        <time class="eq-timeline-time" dateTime="2026-03-12T14:02">14:02</time>
      </div>
      <div class="eq-timeline-actor">Priya Raman</div>
    </div>
  </li>
  <li class="eq-timeline-item" data-tone="ok">
    <span class="eq-timeline-marker" aria-hidden="true">
      <span class="eq-timeline-dot"></span>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Checks passed</span>
        <time class="eq-timeline-time" dateTime="2026-03-12T14:06">14:06</time>
      </div>
    </div>
  </li>
  <li class="eq-timeline-item" data-tone="danger">
    <span class="eq-timeline-marker" aria-hidden="true">
      <span class="eq-timeline-dot"></span>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Rollout failed</span>
        <time class="eq-timeline-time" dateTime="2026-03-12T14:09">14:09</time>
      </div>
    </div>
  </li>
</ol>

Examples

Grouped by day

Each group is a nested list named by its label, so a screen reader announces "Today, list, 2 items".

    1. Incident resolved
      Dana Ortiz
    2. Error rate back under 0.1%
      Alerting
    1. Paged on-call: checkout 5xx above 2%
      Alerting
    2. Config change to payment retries
      Sam Lee
Code
import { Timeline, TimelineGroup, TimelineItem } from "@/components/equip/timeline";

export default function Example() {
  return (
    <Timeline label="Incident 311 activity" style={{ maxWidth: 360 }}>
      <TimelineGroup label="Today">
        <TimelineItem title="Incident resolved" time="09:41" dateTime="2026-03-12T09:41" actor="Dana Ortiz" tone="ok" />
        <TimelineItem title="Error rate back under 0.1%" time="09:30" dateTime="2026-03-12T09:30" actor="Alerting" />
      </TimelineGroup>
      <TimelineGroup label="Yesterday">
        <TimelineItem title="Paged on-call: checkout 5xx above 2%" time="23:12" dateTime="2026-03-11T23:12" actor="Alerting" tone="danger" />
        <TimelineItem title="Config change to payment retries" time="22:58" dateTime="2026-03-11T22:58" actor="Sam Lee" tone="warn" />
      </TimelineGroup>
    </Timeline>
  );
}
HTMLworks in any framework, with the part's CSS
<ol aria-label="Incident 311 activity" style="max-width:360px" class="eq-timeline">
  <li class="eq-timeline-group">
    <div class="eq-timeline-group-label" aria-hidden="true">Today</div>
    <ol class="eq-timeline-list" aria-label="Today">
      <li class="eq-timeline-item" data-tone="ok">
        <span class="eq-timeline-marker" aria-hidden="true">
          <span class="eq-timeline-dot"></span>
        </span>
        <div class="eq-timeline-body">
          <div class="eq-timeline-head">
            <span class="eq-timeline-title">Incident resolved</span>
            <time class="eq-timeline-time" dateTime="2026-03-12T09:41">09:41</time>
          </div>
          <div class="eq-timeline-actor">Dana Ortiz</div>
        </div>
      </li>
      <li class="eq-timeline-item" data-tone="default">
        <span class="eq-timeline-marker" aria-hidden="true">
          <span class="eq-timeline-dot"></span>
        </span>
        <div class="eq-timeline-body">
          <div class="eq-timeline-head">
            <span class="eq-timeline-title">Error rate back under 0.1%</span>
            <time class="eq-timeline-time" dateTime="2026-03-12T09:30">09:30</time>
          </div>
          <div class="eq-timeline-actor">Alerting</div>
        </div>
      </li>
    </ol>
  </li>
  <li class="eq-timeline-group">
    <div class="eq-timeline-group-label" aria-hidden="true">Yesterday</div>
    <ol class="eq-timeline-list" aria-label="Yesterday">
      <li class="eq-timeline-item" data-tone="danger">
        <span class="eq-timeline-marker" aria-hidden="true">
          <span class="eq-timeline-dot"></span>
        </span>
        <div class="eq-timeline-body">
          <div class="eq-timeline-head">
            <span class="eq-timeline-title">Paged on-call: checkout 5xx above 2%</span>
            <time class="eq-timeline-time" dateTime="2026-03-11T23:12">23:12</time>
          </div>
          <div class="eq-timeline-actor">Alerting</div>
        </div>
      </li>
      <li class="eq-timeline-item" data-tone="warn">
        <span class="eq-timeline-marker" aria-hidden="true">
          <span class="eq-timeline-dot"></span>
        </span>
        <div class="eq-timeline-body">
          <div class="eq-timeline-head">
            <span class="eq-timeline-title">Config change to payment retries</span>
            <time class="eq-timeline-time" dateTime="2026-03-11T22:58">22:58</time>
          </div>
          <div class="eq-timeline-actor">Sam Lee</div>
        </div>
      </li>
    </ol>
  </li>
</ol>

Short and folded detail

Pass summary to fold long detail into a details element; without it the detail shows inline.

  1. Tagged v2.4.1
    Priya Raman
    Bumps the API client and drops Node 18.
  2. Migration 0042 failed on eu-west-1
    Deploy bot
    Show 4 log lines
    ALTER TABLE invoices ADD COLUMN due_at timestamptz
    ERROR: lock timeout after 30s
    relation "invoices" is locked by pid 8812
    rolled back 0042
Code
import { Timeline, TimelineItem } from "@/components/equip/timeline";

export default function Example() {
  return (
    <Timeline label="Release v2.4.1" style={{ maxWidth: 380 }}>
      <TimelineItem title="Tagged v2.4.1" time="Mar 12, 10:15" dateTime="2026-03-12T10:15" actor="Priya Raman">
        Bumps the API client and drops Node 18.
      </TimelineItem>
      <TimelineItem
        title="Migration 0042 failed on eu-west-1"
        time="Mar 12, 10:22"
        dateTime="2026-03-12T10:22"
        actor="Deploy bot"
        tone="danger"
        summary="Show 4 log lines"
      >
        <pre style={{ margin: 0, whiteSpace: "pre-wrap", fontFamily: "var(--eq-font-mono)" }}>
          {"ALTER TABLE invoices ADD COLUMN due_at timestamptz\nERROR: lock timeout after 30s\nrelation \"invoices\" is locked by pid 8812\nrolled back 0042"}
        </pre>
      </TimelineItem>
    </Timeline>
  );
}
HTMLworks in any framework, with the part's CSS
<ol aria-label="Release v2.4.1" style="max-width:380px" class="eq-timeline">
  <li class="eq-timeline-item" data-tone="default">
    <span class="eq-timeline-marker" aria-hidden="true">
      <span class="eq-timeline-dot"></span>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Tagged v2.4.1</span>
        <time class="eq-timeline-time" dateTime="2026-03-12T10:15">Mar 12, 10:15</time>
      </div>
      <div class="eq-timeline-actor">Priya Raman</div>
      <div class="eq-timeline-detail">Bumps the API client and drops Node 18.</div>
    </div>
  </li>
  <li class="eq-timeline-item" data-tone="danger">
    <span class="eq-timeline-marker" aria-hidden="true">
      <span class="eq-timeline-dot"></span>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Migration 0042 failed on eu-west-1</span>
        <time class="eq-timeline-time" dateTime="2026-03-12T10:22">Mar 12, 10:22</time>
      </div>
      <div class="eq-timeline-actor">Deploy bot</div>
      <details class="eq-timeline-details">
        <summary class="eq-timeline-summary"><svg class="eq-timeline-chevron" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 3 7.5 6l-3 3" /></svg>Show 4 log lines</summary>
        <div class="eq-timeline-detail">
          <pre style="margin:0;white-space:pre-wrap;font-family:var(--eq-font-mono)">ALTER TABLE invoices ADD COLUMN due_at timestamptz
ERROR: lock timeout after 30s
relation &quot;invoices&quot; is locked by pid 8812
rolled back 0042</pre>
        </div>
      </details>
    </div>
  </li>
</ol>

Icons in rings

  1. Opened by Sam Lee2 days ago
  2. Changes requestedYesterday
    Dana Ortiz
  3. Approved and merged1 hour ago
    Dana Ortiz
Code
import { Timeline, TimelineItem } from "@/components/equip/timeline";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d={d} />
    </svg>
  );
}

export default function Example() {
  return (
    <Timeline label="Pull request 482" style={{ maxWidth: 360 }}>
      <TimelineItem title="Opened by Sam Lee" time="2 days ago" icon={<Icon d="M5 3v10M11 13V8a3 3 0 0 0-3-3H6" />} />
      <TimelineItem title="Changes requested" time="Yesterday" actor="Dana Ortiz" tone="warn" icon={<Icon d="M3 4h10v6H8l-3 3v-3H3z" />} />
      <TimelineItem title="Approved and merged" time="1 hour ago" actor="Dana Ortiz" tone="ok" icon={<Icon d="m4 8.5 2.5 2.5L12 5" />} />
    </Timeline>
  );
}
HTMLworks in any framework, with the part's CSS
<ol aria-label="Pull request 482" style="max-width:360px" class="eq-timeline">
  <li class="eq-timeline-item" data-tone="default">
    <span class="eq-timeline-marker" data-icon="" aria-hidden="true">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="M5 3v10M11 13V8a3 3 0 0 0-3-3H6" />
      </svg>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Opened by Sam Lee</span>
        <span class="eq-timeline-time">2 days ago</span>
      </div>
    </div>
  </li>
  <li class="eq-timeline-item" data-tone="warn">
    <span class="eq-timeline-marker" data-icon="" aria-hidden="true">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="M3 4h10v6H8l-3 3v-3H3z" />
      </svg>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Changes requested</span>
        <span class="eq-timeline-time">Yesterday</span>
      </div>
      <div class="eq-timeline-actor">Dana Ortiz</div>
    </div>
  </li>
  <li class="eq-timeline-item" data-tone="ok">
    <span class="eq-timeline-marker" data-icon="" aria-hidden="true">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4 8.5 2.5 2.5L12 5" />
      </svg>
    </span>
    <div class="eq-timeline-body">
      <div class="eq-timeline-head">
        <span class="eq-timeline-title">Approved and merged</span>
        <span class="eq-timeline-time">1 hour ago</span>
      </div>
      <div class="eq-timeline-actor">Dana Ortiz</div>
    </div>
  </li>
</ol>

Props

PropTypeDefaultWhat it does
labelstringnoneTimeline: names the feed for assistive tech.
labelstringnoneTimelineGroup: the period its events belong to, such as Today.
titleReactNodenoneTimelineItem: what happened. Say the outcome in words.
timestringnoneTimelineItem: when, already formatted, such as 14:02 or 2 hours ago.
dateTimestringnoneTimelineItem: ISO date for the time element, so machines can read it.
actorReactNodenoneTimelineItem: who or what did it.
tone"default" | "ok" | "warn" | "danger""default"TimelineItem: colours the marker.
iconReactNodenoneTimelineItem: replaces the dot with an icon in a ring.
summarystringnoneTimelineItem: folds children into a details element with this text as its summary.
childrenReactNodenoneTimelineItem: optional detail under the title.
...restComponentProps<"ol"> | ComponentProps<"li">noneTimeline spreads onto the ol; TimelineGroup and TimelineItem onto their li.

Anatomy

root.eq-timelineThe ordered list.
group.eq-timeline-group
group label.eq-timeline-group-labelVisible label; the nested list carries it as aria-label.
group list.eq-timeline-list
item.eq-timeline-itemDraws the connecting line to the next item.
marker.eq-timeline-markerA dot, or an icon in a ring.
dot.eq-timeline-dot
body.eq-timeline-body
head.eq-timeline-headTitle and time on one line.
title.eq-timeline-title
time.eq-timeline-timeA time element when dateTime is set.
actor.eq-timeline-actor
detail.eq-timeline-detail
details.eq-timeline-detailsOnly when summary is set.
summary.eq-timeline-summary
chevron.eq-timeline-chevron

Keyboard

EnterSpaceOn a folded summary, opens or closes the detail.

Notes

  • The marker colour is never the only signal: write the outcome into the title, such as Rollout failed.
  • Renders on the server with no client JavaScript; folding uses the native details element.
  • Groups are nested ordered lists named by their label, so the list structure survives for screen readers.
  • Detail only folds when you pass summary, because a server part cannot measure how long the detail is.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/timeline.tsx108 lines
import type { ComponentProps, ReactNode } from "react";
import "./timeline.css";

function cx(base: string, className: string | undefined) {
  return [base, className].filter(Boolean).join(" ");
}

export type TimelineProps = ComponentProps<"ol"> & {
  /** Names the feed for assistive tech, e.g. "Deploy activity". */
  label: string;
};

export function Timeline({ label, className, ...rest }: TimelineProps) {
  return <ol aria-label={label} {...rest} className={cx("eq-timeline", className)} />;
}

export type TimelineGroupProps = Omit<ComponentProps<"li">, "children"> & {
  /** The period the events belong to, e.g. "Today". */
  label: string;
  children: ReactNode;
};

export function TimelineGroup({ label, className, children, ...rest }: TimelineGroupProps) {
  return (
    <li {...rest} className={cx("eq-timeline-group", className)}>
      <div className="eq-timeline-group-label" aria-hidden="true">
        {label}
      </div>
      <ol className="eq-timeline-list" aria-label={label}>
        {children}
      </ol>
    </li>
  );
}

export type TimelineItemProps = Omit<ComponentProps<"li">, "title"> & {
  /** What happened. Say the outcome in words: the marker colour is never the only signal. */
  title: ReactNode;
  /** When it happened, already formatted, e.g. "14:02" or "2 hours ago". */
  time: string;
  /** Machine-readable ISO date for the time element. */
  dateTime?: string;
  /** Who or what did it. */
  actor?: ReactNode;
  tone?: "default" | "ok" | "warn" | "danger";
  /** Replaces the dot with an icon inside a ring. */
  icon?: ReactNode;
  /** When set, the detail folds into a disclosure with this text as its summary. */
  summary?: string;
};

export function TimelineItem({
  title,
  time,
  dateTime,
  actor,
  tone = "default",
  icon,
  summary,
  className,
  children,
  ...rest
}: TimelineItemProps) {
  const when = dateTime ? (
    <time className="eq-timeline-time" dateTime={dateTime}>
      {time}
    </time>
  ) : (
    <span className="eq-timeline-time">{time}</span>
  );
  return (
    <li {...rest} className={cx("eq-timeline-item", className)} data-tone={tone}>
      <span className="eq-timeline-marker" data-icon={icon ? "" : undefined} aria-hidden="true">
        {icon ?? <span className="eq-timeline-dot" />}
      </span>
      <div className="eq-timeline-body">
        <div className="eq-timeline-head">
          <span className="eq-timeline-title">{title}</span>
          {when}
        </div>
        {actor != null && <div className="eq-timeline-actor">{actor}</div>}
        {children != null &&
          (summary ? (
            <details className="eq-timeline-details">
              <summary className="eq-timeline-summary">
                <svg
                  className="eq-timeline-chevron"
                  viewBox="0 0 12 12"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.5"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden="true"
                >
                  <path d="M4.5 3 7.5 6l-3 3" />
                </svg>
                {summary}
              </summary>
              <div className="eq-timeline-detail">{children}</div>
            </details>
          ) : (
            <div className="eq-timeline-detail">{children}</div>
          ))}
      </div>
    </li>
  );
}
components/equip/timeline.css196 lines
@layer equip {
  .eq-timeline,
  .eq-timeline-list {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-timeline {
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-timeline-group + .eq-timeline-group {
    margin-top: var(--eq-space-3);
  }

  .eq-timeline-group-label {
    margin-bottom: var(--eq-space-2);
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
    letter-spacing: 0.01em;
  }

  .eq-timeline-item {
    --_tone: var(--eq-ink-3);

    position: relative;
    display: grid;
    grid-template-columns: 24px minmax(0, 1fr);
    column-gap: var(--eq-space-3);
    padding-bottom: var(--eq-space-2);
  }

  .eq-timeline-item[data-tone="ok"] {
    --_tone: var(--eq-ok);
  }

  .eq-timeline-item[data-tone="warn"] {
    --_tone: var(--eq-warn);
  }

  .eq-timeline-item[data-tone="danger"] {
    --_tone: var(--eq-danger);
  }

  /* The connecting line runs from just under this dot to just above the next one,
     reaching into the next item; rings sit on top of it. */
  .eq-timeline-item:not(:last-child)::before {
    content: "";
    position: absolute;
    inset-block: 20px -4px;
    inset-inline-start: 11.5px;
    width: 1px;
    background: var(--eq-line-strong);
  }

  .eq-timeline-item:last-child {
    padding-bottom: 0;
  }

  .eq-timeline-marker {
    position: relative;
    z-index: 1;
    display: grid;
    place-items: center;
    width: 24px;
    height: 24px;
    color: var(--_tone);
  }

  .eq-timeline-marker[data-icon] {
    box-sizing: border-box;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-full);
    background: var(--eq-raised);
  }

  .eq-timeline-marker > svg {
    width: 14px;
    height: 14px;
  }

  .eq-timeline-dot {
    width: 10px;
    height: 10px;
    border-radius: var(--eq-radius-full);
    background: var(--_tone);
  }

  .eq-timeline-body {
    min-width: 0;
    padding-top: 2px;
  }

  .eq-timeline-head {
    display: flex;
    align-items: baseline;
    gap: var(--eq-space-2);
  }

  .eq-timeline-title {
    flex: 1;
    min-width: 0;
    font-weight: var(--eq-weight-strong);
    line-height: 20px;
    overflow-wrap: anywhere;
  }

  .eq-timeline-time {
    flex: none;
    color: var(--eq-ink-3);
    font-size: var(--eq-text-xs);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  .eq-timeline-actor {
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
    line-height: 16px;
  }

  .eq-timeline-detail {
    margin-top: var(--eq-space-1);
    color: var(--eq-ink-2);
    overflow-wrap: anywhere;
  }

  .eq-timeline-details {
    margin-top: var(--eq-space-1);
  }

  .eq-timeline-summary {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-height: 24px;
    padding-inline-end: var(--eq-space-1);
    border-radius: var(--eq-radius-s);
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
    list-style: none;
    cursor: pointer;
    user-select: none;
    transition:
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-timeline-summary::-webkit-details-marker {
    display: none;
  }

  @media (hover: hover) {
    .eq-timeline-summary:hover {
      color: var(--eq-ink);
    }
  }

  .eq-timeline-summary:active {
    scale: var(--eq-press);
  }

  .eq-timeline-summary:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-timeline-chevron {
    flex: none;
    width: 12px;
    height: 12px;
    transition: rotate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-timeline-details[open] > .eq-timeline-summary > .eq-timeline-chevron {
    rotate: 90deg;
  }

  .eq-timeline-details > .eq-timeline-detail {
    padding: var(--eq-space-2) var(--eq-space-3);
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-m);
  }

  .eq-timeline-chevron:dir(rtl) {
    scale: -1 1;
  }

  .eq-timeline-details[open] > .eq-timeline-summary > .eq-timeline-chevron:dir(rtl) {
    rotate: -90deg;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index