component

Timeline

A sequence of entries on a rail: an activity log, a comment thread, an audit trail or a step tracker. Every entry is the same anatomy, so these mix freely in one list:

  • a marker on the rail: a dot, a Stamp with an icon, an Avatar, or nothing
  • a header: a Banner with an optional eyebrow (a time, an id, a stage), the heading, and the meta (usually the date) on its own muted line
  • an optional body: text, a Card, a quote
  • an optional actions row: buttons, reactions, reply counts

The root is a role="list" and every entry a role="listitem", so assistive tech announces how many entries there are before reading them.

Activity log

A dense log mixes one-line events with richer entries. Leave the marker empty for a quiet dot, or drop a Stamp or Avatar into it. Whatever you put there is resized to the rail’s marker box, so a size="lg" stamp still lines up. Put the actor and the action in the heading slot: <strong>, <b> and <a> read bolder than the surrounding text.

TimelineSeparator starts a new section (“Today”, “Last week”). Its label is a Banner eyebrow (the same small uppercase kicker used across the system), set at the row’s start edge, and the rail breaks around it, so each section gets its own line. TimelineCollapse hides a run of entries behind a “Show N more” toggle.

---
// GENERATED from packages/demos/timeline/activity.demo.tsx — edit the source, not this file.
import Avatar from "@pindoba/astro-avatar";
import Button from "@pindoba/astro-button";
import Card from "@pindoba/astro-card";
import Stamp from "@pindoba/astro-stamp";
import {
  Timeline,
  TimelineCollapse,
  TimelineItem,
  TimelineSeparator,
} from "@pindoba/astro-timeline";
import { GitPullRequest, Tag, CircleCheck } from "@lucide/astro";

const NOW = new Date(2026, 9, 16, 15, 0);
const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000);
const shared = { now: NOW, locale: "en-US", dateFormat: "relative" } as const;
---

<Timeline size="sm">
  <TimelineSeparator label="Today" />
  <TimelineItem {...shared} date={ago(12)}>
    <Fragment slot="heading">
      <strong>Marta Silva</strong> renamed the issue to{" "}
      <strong>Retune the danger ramp</strong>
    </Fragment>
  </TimelineItem>
  <TimelineItem {...shared} date={ago(40)}>
    <Avatar
      slot="marker"
      name="Lucía Ortega"
      src="https://i.pravatar.cc/96?u=lucia"
    />
    <Fragment slot="heading">
      <strong>Lucía Ortega</strong> commented
    </Fragment>
    <Card size="sm" border="default">
      The 600 step fails AA on the dark page by a hair. Nudging lightness by two
      points fixes it without touching the hue.
    </Card>
  </TimelineItem>
  <TimelineItem {...shared} date={ago(95)}>
    <Stamp slot="marker" tone="primary" shape="circle">
      <GitPullRequest />
    </Stamp>
    <Fragment slot="heading">
      <strong>Kenji Mori</strong> asked to merge{" "}
      <strong>tokens/danger-600</strong>
    </Fragment>
    <Fragment slot="actions">
      <Button size="sm" emphasis="secondary">
        Decline
      </Button>
      <Button size="sm" emphasis="primary">
        Approve
      </Button>
    </Fragment>
  </TimelineItem>
  <TimelineCollapse count={3}>
    <TimelineItem {...shared} date={ago(130)}>
      <Fragment slot="heading">
        <strong>Kenji Mori</strong> pushed 2 commits
      </Fragment>
    </TimelineItem>
    <TimelineItem {...shared} date={ago(160)}>
      <Stamp slot="marker" emphasis="ghost" shape="circle">
        <Tag />
      </Stamp>
      <Fragment slot="heading">
        <strong>Marta Silva</strong> added the <strong>color</strong> label
      </Fragment>
    </TimelineItem>
    <TimelineItem {...shared} date={ago(200)}>
      <Fragment slot="heading">
        <strong>Marta Silva</strong> opened the issue
      </Fragment>
    </TimelineItem>
  </TimelineCollapse>
  <TimelineSeparator label="Last week" />
  <TimelineItem {...shared} date={ago(60 * 24 * 6)} tone="success">
    <Stamp slot="marker" tone="success" shape="circle">
      <CircleCheck />
    </Stamp>
    <Fragment slot="heading">
      Checks passed on <strong>main</strong>
    </Fragment>
  </TimelineItem>
</Timeline>

Header tiers

Each part of an entry gets its own tier, so the date never competes with the title. The header is a Banner:

  • eyebrow: an optional kicker above the heading: a time, an id, a pipeline stage. The marker stays centred on the heading line, not on the kicker.
  • heading: title, or the heading slot for rich content.
  • meta: the formatted date, or the meta slot. Where it sits depends on metaPlacement:
    • below (default): its own muted line under the heading.
    • above: the kicker above the heading, after the eyebrow when there is one. Use it for schedules, where the time leads.
    • inline: after the heading on the same line, for dense logs such as an audit trail.

passThrough.header, heading, eyebrow and meta style each tier. They are applied to the Banner’s rows, so they follow the meta wherever metaPlacement puts it.

Comments and threads

A comment is an entry with an avatar marker, the author as title, a relative date and the text as its body. For replies, nest a Timeline in the body. A nested timeline with no size of its own drops to sm, so the thread reads as subordinate to its parent. Collapse long threads with TimelineCollapse and a custom labels.more.

---
// GENERATED from packages/demos/timeline/comments.demo.tsx — edit the source, not this file.
import {
  Timeline,
  TimelineCollapse,
  TimelineItem,
} from "@pindoba/astro-timeline";
import Avatar from "@pindoba/astro-avatar";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Reply, Heart } from "@lucide/astro";

const NOW = new Date(2026, 9, 16, 15, 0);
const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000);
const shared = { now: NOW, locale: "en-US", dateFormat: "relative" } as const;
---

<Timeline line="none">
  <TimelineItem {...shared} title="Priya Nair" date={ago(180)}>
    <Avatar
      slot="marker"
      name="Priya Nair"
      src="https://i.pravatar.cc/96?u=priya"
    />
    Should the compact density ship in this release, or wait until the table
    supports it too?
    <Timeline>
      <TimelineItem {...shared} title="Tomás Reyes" date={ago(150)}>
        <Avatar slot="marker" name="Tomás Reyes" />
        Wait. Half a density story is worse than none.
      </TimelineItem>
      <TimelineCollapse
        count={2}
        labels={{ more: (n: number | undefined) => `${n} more replies` }}
      >
        <TimelineItem {...shared} title="Priya Nair" date={ago(120)}>
          <Avatar
            slot="marker"
            name="Priya Nair"
            src="https://i.pravatar.cc/96?u=priya"
          />
          Fair. The table work is two weeks out.
        </TimelineItem>
        <TimelineItem {...shared} title="Tomás Reyes" date={ago(90)}>
          <Avatar slot="marker" name="Tomás Reyes" />I can pair on it next
          sprint.
        </TimelineItem>
      </TimelineCollapse>
      <TimelineItem {...shared} title="Ines Duarte" date={ago(30)}>
        <Avatar slot="marker" name="Ines Duarte" />
        <Fragment slot="actions">
          <Button size="xs" emphasis="ghost">
            <Stamp slot="leading" emphasis="ghost">
              <Reply />
            </Stamp>
            Reply
          </Button>
          <Button size="xs" emphasis="ghost" aria-label="Like">
            <Stamp slot="leading" emphasis="ghost">
              <Heart />
            </Stamp>
          </Button>
        </Fragment>
        Agreed. Moving it to the next milestone.
      </TimelineItem>
    </Timeline>
  </TimelineItem>
  <TimelineItem {...shared} title="Ines Duarte" date={ago(10)}>
    <Avatar slot="marker" name="Ines Duarte" />
    <Fragment slot="actions">
      <Button size="xs" emphasis="ghost">
        <Stamp slot="leading" emphasis="ghost">
          <Reply />
        </Stamp>
        Reply
      </Button>
      <Button size="xs" emphasis="ghost" aria-label="Like">
        <Stamp slot="leading" emphasis="ghost">
          <Heart />
        </Stamp>
      </Button>
    </Fragment>
    Release notes draft is up — tone welcome before Friday.
  </TimelineItem>
</Timeline>

Collapsing entries

TimelineCollapse renders a toggle row on the rail, followed by its children: real TimelineItems in a region that is hidden until expanded. Both wrappers are display: contents with role="none", so once shown, the entries are ordinary list items and the rail runs through them unbroken.

  • The toggle is a Button with aria-expanded and aria-controls pointing at the region.
  • count feeds the default “Show N more” text. labels takes { more, less }, and more can be a function of the count.
  • In Svelte, expanded is bindable. In Astro, a small script from @pindoba/core-timeline/runtime toggles it. It needs no framework and re-binds after View Transitions.

Markers

Marker slotRenders
emptya dot in the item’s tone colour (neutral by default)
a Stamp or Avatarthat component, sized to the rail
empty, with state seta state stamp (see Step states)
hideMarkernothing; the line runs straight through

bleed lets an entry’s content span the rail too: a full-width card that interrupts the line.

---
// GENERATED from packages/demos/timeline/tone.demo.tsx — edit the source, not this file.
import Stamp from "@pindoba/astro-stamp";
import { Timeline, TimelineItem } from "@pindoba/astro-timeline";
import {
  Rocket,
  Info,
  CircleCheck,
  TriangleAlert,
  ShieldAlert,
} from "@lucide/astro";
---

<Timeline>
  <TimelineItem tone="neutral" title="Neutral">
    A plain dot — the default for activity entries.
  </TimelineItem>
  <TimelineItem tone="primary" title="Primary">
    <Stamp slot="marker" tone="primary" shape="circle">
      <Rocket />
    </Stamp>
    Version 2.4 was released.
  </TimelineItem>
  <TimelineItem tone="neutral" title="Note">
    <Stamp slot="marker" emphasis="tertiary" shape="circle">
      <Info />
    </Stamp>
    The changelog was updated.
  </TimelineItem>
  <TimelineItem tone="success" title="Success">
    <Stamp slot="marker" tone="success" shape="circle">
      <CircleCheck />
    </Stamp>
    The pipeline passed.
  </TimelineItem>
  <TimelineItem tone="warning" title="Warning">
    <Stamp slot="marker" tone="warning" shape="circle">
      <TriangleAlert />
    </Stamp>
    Bundle size grew by 40kb.
  </TimelineItem>
  <TimelineItem tone="danger" title="Danger">
    <Stamp slot="marker" tone="danger" emphasis="primary" shape="circle">
      <ShieldAlert />
    </Stamp>
    A hardcoded secret was found.
  </TimelineItem>
</Timeline>

Sizes and lines

size sets every rail measurement at once: the marker box, dot, line width, spacing and text size. The marker box follows the control scale, so a marker lines up with buttons of the same size.

  • sm for dense logs
  • md (the default) for general use
  • lg for roomy step trackers

line draws the connector solid (the default), dashed or none. With none the marker column stays, so markers still align.

---
// GENERATED from packages/demos/timeline/sizes.demo.tsx — edit the source, not this file.
import { Timeline, TimelineItem } from "@pindoba/astro-timeline";
import { css } from "@pindoba/styled-system/css";

const variants = [
  { size: "sm", line: "solid" },
  { size: "md", line: "dashed" },
  { size: "lg", line: "none" },
] as const;
---

<div
  class={css({
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
    gap: "xl",
  })}
>
  {variants.map((v) => (
    <Timeline size={v.size} line={v.line}>
      <TimelineItem title={`size="${v.size}"`} />
      <TimelineItem state="done" title={`line="${v.line}"`} />
      <TimelineItem state="current" title="In review" />
      <TimelineItem state="upcoming" title="Released" />
    </Timeline>
  ))}
</div>

Dates

date takes a single Date or a [start, end] range and renders in the meta line. Formatting lives in core, so every framework prints the same string:

  • A range that ends today and is still running reads as ”… - now”. A date-only end runs through today; an end with a time reads “now” only while the range is running, so a meeting that ended this morning stays a plain range.
  • Malformed input degrades to Invalid date rather than throwing mid-render.
  • locale formats for a specific BCP 47 locale. It defaults to the runtime’s.

How dates are written:

  • Default: the day only ({ dateStyle: "long" }).
  • dateFormat: "relative": “3 days ago” or “yesterday”, in the largest whole unit. The absolute date goes in the element’s title. Relative text does not update on its own, so re-render to refresh it. Pass now to pin the reference instant, which keeps server and client output identical.
  • dateFormat with Intl.DateTimeFormat options: for example { dateStyle: "long", timeStyle: "short" } to add the time. An invalid combination falls back to the default.
  • formatDate: a (date, locale) => string function. It wins over dateFormat.

Each date is a machine-readable <time datetime="…">:

  • datetime is date-only (2026-01-05) for a Date at local midnight.
  • It is the full ISO instant otherwise, or when dateFormat.timeZone is set.
  • The “now” end of a range is plain text: it names no fixed day.

passThrough.time styles every <time>. The meta slot replaces the date line entirely. Several elements in it (a link, a count, a time) are joined with a ”·”.

---
// GENERATED from packages/demos/timeline/dates.demo.tsx — edit the source, not this file.
import { Timeline, TimelineItem } from "@pindoba/astro-timeline";

const START = new Date(2026, 0, 5);
const END = new Date(2026, 2, 9);
const DEPLOYED = new Date(2026, 2, 9, 14, 30);
const NOW = new Date(2026, 2, 12, 9, 0);
---

<Timeline>
  <TimelineItem date={START} locale="en-US">
    A date on its own fills the header.
  </TimelineItem>
  <TimelineItem title="Closed range" date={[START, END]} locale="en-US">
    Both ends are shown.
  </TimelineItem>
  <TimelineItem title="Still running" date={[START, new Date()]} locale="en-US">
    A range ending today reads as "… - now".
  </TimelineItem>
  <TimelineItem
    title="With the time"
    date={DEPLOYED}
    locale="en-US"
    dateFormat={{
      year: "numeric",
      month: "long",
      day: "numeric",
      hour: "numeric",
      minute: "2-digit",
    }}
  >
    <code>dateFormat</code> takes <code>Intl.DateTimeFormat</code> options.
  </TimelineItem>
  <TimelineItem
    title="Relative"
    date={DEPLOYED}
    dateFormat="relative"
    now={NOW}
    locale="en-US"
  >
    Hover the time for the absolute date.
  </TimelineItem>
</Timeline>

Step states

To track progress through known steps (an order going Received → In production → Ready → Picked up), give each entry a state. With no custom marker, the marker becomes a state stamp, and tone defaults to primary:

  • done: a filled stamp with a check. The line after it takes the item’s colour.
  • current: a filled stamp with a ring. The entry carries aria-current="step".
  • upcoming: a hollow, muted stamp. The heading and body use the muted text colour, a solved token that stays readable, never an opacity fade.

A line leading into an upcoming step is muted even when it starts at a done one. The last entry draws no line. That is plain CSS keyed on the item being the timeline’s last child, so render entries as direct children of Timeline, or of a TimelineCollapse region.

---
// GENERATED from packages/demos/timeline/steps.demo.tsx — edit the source, not this file.
import { Timeline, TimelineItem } from "@pindoba/astro-timeline";

const RECEIVED = new Date(2026, 8, 28, 9, 12);
const IN_PRODUCTION = new Date(2026, 8, 28, 9, 40);
const TIME = {
  hour: "numeric",
  minute: "2-digit",
} as const;
---

<Timeline>
  <TimelineItem
    state="done"
    title="Received"
    date={RECEIVED}
    dateFormat={TIME}
    locale="en-US"
  >
    Order #1042 — 2 flat whites, 1 croissant.
  </TimelineItem>
  <TimelineItem
    state="done"
    title="In production"
    date={IN_PRODUCTION}
    dateFormat={TIME}
    locale="en-US"
  >
    The kitchen started on it.
  </TimelineItem>
  <TimelineItem state="current" title="Ready">
    Waiting at the counter.
  </TimelineItem>
  <TimelineItem state="upcoming" title="Picked up">
    Show the code 1042 at pickup.
  </TimelineItem>
</Timeline>

A cancelled order stops early. Its last step is the current one, with tone="danger" and its own marker.

---
// GENERATED from packages/demos/timeline/steps-cancelled.demo.tsx — edit the source, not this file.
import Stamp from "@pindoba/astro-stamp";
import { Timeline, TimelineItem } from "@pindoba/astro-timeline";
import { X } from "@lucide/astro";
---

<Timeline>
  <TimelineItem state="done" title="Received">
    Order #1043 — 1 espresso.
  </TimelineItem>
  <TimelineItem state="done" title="In production">
    The kitchen started on it.
  </TimelineItem>
  <TimelineItem
    state="current"
    tone="danger"
    title="Cancelled"
    labels={{ current: "Final status" }}
  >
    <Stamp slot="marker" emphasis="primary" tone="danger" shape="circle">
      <X />
    </Stamp>
    Out of oat milk — the payment was refunded.
  </TimelineItem>
</Timeline>

Announcing the state

A screen reader can’t see the marker, so every entry with a state also renders a visually hidden word after its heading: “Completed”, “Current step” or “Not started”. Localize it with labels. Keys you leave out keep the English default:

<TimelineItem state="done" title="Recebido" labels={{ done: "Concluído" }} />

If the visible content already names the state (a “Cancelled” title, a status badge), turn the word off. labels={false} removes it for every state, and an empty string removes it for one (labels={{ current: "" }}).

Framework support

TimelineSeparator and TimelineCollapse ship for Svelte and Astro. React, Vue and Solid render Timeline and TimelineItem with the full anatomy (marker, heading, meta, body and actions); the separator and collapse ports are tracked in the core roadmap.

  • Components
  • Blocks