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:
Stamp with an icon, an Avatar, or nothingBanner with an optional eyebrow (a time, an id, a stage), the heading, and the meta (usually the date) on its own muted lineCard, a quoteThe root is a role="list" and every entry a role="listitem", so assistive tech announces how many entries there are before reading them.
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>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.title, or the heading slot for rich content.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.
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>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.
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.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.| Marker slot | Renders |
|---|---|
| empty | a dot in the item’s tone colour (neutral by default) |
a Stamp or Avatar | that component, sized to the rail |
empty, with state set | a state stamp (see Step states) |
hideMarker | nothing; 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>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 logsmd (the default) for general uselg for roomy step trackersline 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>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:
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:
{ 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.dateFormat.timeZone is set.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>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>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: "" }}).
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.