Timeline

Free

9 premium variations: horizontal, alternate zigzag, numbered stepper, check stepper, activity feed, status tracker, rich card, date-grouped and interactive (clickable rows).

</>sv-timeline timelineactivityhistory

Complexity tier: Interactive The primitive bar, plus declared async states, a keyboard model, focus restoration and undo for destructive actions.

import { SvTimelineComponent } from '@styloviz/timeline';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Activity feed grouped by date

example.htmlAngular
<sv-timeline [items]="events" displayMode="feed" [groupByDate]="true" />

Horizontal stepper

example.htmlAngular
<sv-timeline [items]="steps" layout="horizontal" [activeIndex]="2" />

Rich cards with CTA handler

example.htmlAngular
<sv-timeline
  [items]="releases"
  displayMode="rich"
  (actionClick)="openRelease($event)" />

Interactive (clickable rows)

example.htmlAngular
<sv-timeline
  [items]="events"
  displayMode="interactive"
  [(selectedId)]="selectedId"
  (itemClick)="open($event)" />

Status / Progress Tracker

displayMode="status" (or dotStyle="check" with activeIndex) turns the timeline into an order/onboarding progress tracker with complete / active / pending steps.

example.htmlAngular
<sv-timeline
  [items]="steps"
  displayMode="status"
  [activeIndex]="2" />

What the list means, separately from what it looks like

A stepper is not a feed. `displayMode` is an appearance choice and it was silently deciding the semantics too — worse, in the default layout "is this a stepper?" was inferred from `dotStyle`, so whether a screen reader was told where the reader is in a process depended on the shape of the dot. Pick a plain circle and the current item announced as nothing; pick a tick and it announced as a step. `intent` separates the two: a process says aria-current="step", a chronology says "true", and a feed says nothing at all, because a log of events has no position to be at. It also names the list — "Timeline", "Progress tracker", "Activity feed". The default, auto, keeps the old inference (feed from displayMode="feed", process from "status" or a ticked/numbered dotStyle, chronology otherwise) but not every old announcement: an inferred chronology with an active item now says aria-current="true" where it used to say nothing, and each list is named after its intent. Set intent explicitly to pin the semantics.

example.htmlAngular
<!-- A process, drawn with plain dots, that still announces as one. -->
<sv-timeline [items]="steps" intent="process" [activeIndex]="2" />

<!-- A log. Nothing in it is "current". -->
<sv-timeline [items]="events" intent="feed" />

Collapsing a long history

A long history is not a list, it is a wall. `collapseAfter` renders the first N items in the host's order and a "Show 45 more entries" control that names the count; `expanded` is two-way so a host can persist it. It applies to every layout, including `groupByDate` — a date group whose items are all collapsed is not drawn until the reader expands.

example.htmlAngular
<sv-timeline
  [items]="auditLog"
  [collapseAfter]="5"
  [(expanded)]="auditExpanded"
  [groupByDate]="true" />

When to use

  • Showing a sequence of events over time: history, activity, status changes.
  • You want per-item icons/colors and optional date grouping.
  • For an interactive multi-step process, use the Stepper.

Async states

  • loading
  • empty
  • error
  • retry

Inputs

NameTypeDefaultDescription
itemsreadonly TimelineItem[][]Array of timeline events to render.
layoutTimelineLayout'left'Structural layout direction.
sizeTimelineSize'md'Component size preset.
showConnectorbooleantrueShow connector lines between dots.
customClassstring''Additional CSS classes on the root element.
ariaLabelstring''What this timeline is, e.g. `'Deployment history'` — names the list and, in the horizontal layout, the scrollable region a keyboard user tabs into. Every list here used to be hardcoded `aria-label="Timeline"`. A release page with a deploy log beside an audit trail announced both as "Timeline", which is the role restated and no help in telling them apart. Left unset, the layout's own generic name is kept. Matches `sv-timeline-pro`.
dotStyleTimelineDotStyle'dot'Dot rendering style.
displayModeTimelineDisplayMode'default'Content display mode.
intentTimelineIntent | 'auto''auto'What the list means. `'auto'` infers it from `displayMode`.
activeIndexnumber-1Zero-based index of the currently active step. Items before → `complete`; this item → `active`; after → `pending`. Defaults to `-1` (uses each item's own `itemStatus`).
groupByDatebooleanfalseGroup consecutive items under the `groupDate` header they share. THE COMPONENT NEVER DERIVES A DAY FROM A TIMESTAMP, AND THAT IS THE ZONE POLICY. "Tuesday" depends on who is reading — an event at 23:30 in Berlin is Wednesday in Auckland and still Tuesday in New York — so a component that grouped by parsing a timestamp would have to pick somebody's Tuesday, and any choice is wrong for the others. `groupDate` is a LABEL the host supplies, already resolved in the zone it wants displayed, and consecutive items sharing it are grouped. That makes the zone the host's decision, which is the only place it can correctly be made. Same policy as `sv-timeline-pro`.
collapseAfternumber0Show only this many items until the reader asks for the rest. `0` shows all. A LONG HISTORY IS NOT A LIST, IT IS A WALL. Five hundred entries push everything else off the page and cost a screen-reader user five hundred stops to get past. This is in the FREE tier deliberately. A timeline that becomes unusable at scale is the defect, not a smaller version of one — and without it a free host can only mitigate a long history by not receiving it.
expandedboolean (two-way)falseWhether the collapsed remainder is currently shown (two-way). A `model` so a host can persist it: a reader who expanded a history and navigated away should not have to expand it again.
horizontalAlignTimelineHorizontalAlign'alternate'Content placement for `layout='horizontal'`.
showVariantIconbooleanfalseDraw a status glyph inside `success` / `error` / `warning` / `info` dots that have no `item.icon` of their own — a tick, a cross, a triangle, an "i". The dot's meaning is already announced (see `variantLabel`), but for a sighted reader it was still a hue alone. A shape survives colour-vision deficiency, greyscale printing and a theme that happens to put two tones close together. Opt-in so existing plain dots are unchanged.
loadingbooleanfalseTrue while the first page of events is being fetched. Replaces the timeline with a placeholder. For an additional page use `loadingOlder` or `loadingNewer`, which keep the events already on screen.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
emptyMessagestring'No events to show'Copy for the empty state.
hasOlderbooleanfalseOlder events exist further back in history.
loadingOlderbooleanfalseTrue while the previous page of history is being fetched.
hasNewerbooleanfalseNewer events exist — for a feed that is still being written to.
loadingNewerbooleanfalseTrue while newer events are being fetched.
selectedIdstring | number | null (two-way)nullCurrently selected item id (two-way). Use `[(selectedId)]` with `displayMode="interactive"` for a selectable timeline.

Outputs

EventPayload typeDescription
itemClickTimelineItemEmitted when an interactive row is activated (click / Enter / Space). Only fires when `displayMode='interactive'`.
actionClickTimelineItemEmitted when a `rich`-card CTA button is activated. Fires alongside any `actionUrl` navigation so you can handle the action in code.
loadMoreTimelineLoadMoreEventThe user asked for another page. `direction` says which end: `'older'` for history below, `'newer'` for a feed that has moved on above.
retryvoidThe host should re-run the failed load.
expandedChangebooleanEmits the new `expanded` whenever it changes — the output half of `[(expanded)]`.
selectedIdChangestring | number | nullEmits the new `selectedId` whenever it changes — the output half of `[(selectedId)]`.

<sv-timeline-pro>

Also in this family — SvTimelineProComponent from @styloviz-pro/timeline.

Async states

  • loading
  • empty
  • error
  • retry

Inputs

NameTypeDefaultDescription
itemsreadonly TimelineItem[][]Array of timeline events to render.
layoutTimelineLayout'left'Structural layout direction.
sizeTimelineSize'md'Component size preset.
showConnectorbooleantrueShow connector lines between dots.
customClassstring''Additional CSS classes on the root element.
ariaLabelstring''What this timeline is, e.g. `'Deployment history'` — names the list and, in the horizontal layout, the scrollable region a keyboard user tabs into. Every list here used to be hardcoded `aria-label="Timeline"`. A release page with a deploy log beside an audit trail announced both as "Timeline", which is the role restated and no help in telling them apart. Left unset, the layout's own generic name is kept.
dotStyleTimelineDotStyle'dot'Dot rendering style.
displayModeTimelineDisplayMode'default'Content display mode. - `default` · `feed` · `status` · `rich` - `interactive`: every item is a focusable button that emits `itemClick` and highlights the selected item (bind `[(selectedId)]` to control it).
intentTimelineIntent | 'auto''auto'What the list means. `'auto'` infers it from `displayMode`.
selectedIdstring | number | null (two-way)nullCurrently selected item id — used by `displayMode='interactive'` to highlight the active row. Two-way bindable; also updated automatically when an item is activated.
activeIndexnumber-1Zero-based index of the currently active step. Items before → `complete`; this item → `active`; after → `pending`. Defaults to `-1` (uses each item's own `itemStatus`).
groupByDatebooleanfalseGroup consecutive items under the `groupDate` header they share. THE COMPONENT NEVER DERIVES A DAY FROM A TIMESTAMP, AND THAT IS THE ZONE POLICY. "Tuesday" depends on who is reading — an event at 23:30 in Berlin is Wednesday in Auckland and still Tuesday in New York — so a component that grouped by parsing a timestamp would have to pick somebody's Tuesday, and any choice is wrong for the others. `groupDate` is a LABEL the host supplies, already resolved in the zone it wants displayed, and consecutive items sharing it are grouped. That makes the zone the host's decision, which is the only place it can correctly be made.
collapseAfternumber0Show only this many items until the reader asks for the rest. `0` shows all. A LONG HISTORY IS NOT A LIST, IT IS A WALL. Five hundred entries pushes everything else off the page, costs a screen-reader user five hundred stops to get past, and buries the recent items the reader almost always came for. `loadMore` already covers fetching more from a server; this covers the opposite problem — too much already in hand.
expandedboolean (two-way)falseWhether the collapsed remainder is currently shown (two-way). A `model` rather than internal state so a host can persist it: a reader who expanded a history and navigated away should not have to expand it again.
horizontalAlignTimelineHorizontalAlign'alternate'Content placement for `layout='horizontal'`.
showVariantIconbooleanfalseDraw a status glyph inside `success` / `error` / `warning` / `info` dots that have no `item.icon` of their own — a tick, a cross, a triangle, an "i". The dot's meaning is already announced (see `variantLabel`), but for a sighted reader it was still a hue alone. A shape survives colour-vision deficiency, greyscale printing and a theme that happens to put two tones close together. Opt-in so existing plain dots are unchanged.
loadingbooleanfalseTrue while the first page of events is being fetched. Replaces the timeline with a placeholder. For an additional page use `loadingOlder` or `loadingNewer`, which keep the events already on screen.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
emptyMessagestring'No events to show'Copy for the empty state.
hasOlderbooleanfalseOlder events exist further back in history.
loadingOlderbooleanfalseTrue while the previous page of history is being fetched.
hasNewerbooleanfalseNewer events exist — for a feed that is still being written to.
loadingNewerbooleanfalseTrue while newer events are being fetched.

Outputs

EventPayload typeDescription
actionClickTimelineItemEmitted when a `displayMode='rich'` item's CTA is activated. Fires for both link CTAs (with `actionUrl`) and button CTAs (without), letting the host run client-side routing or analytics.
itemClickTimelineItemEmitted when an item is activated in `displayMode='interactive'` (click, Enter or Space on the item button).
loadMoreTimelineLoadMoreEventThe user asked for another page. `direction` says which end: `'older'` for history below, `'newer'` for a feed that has moved on above. `loaded` is how many events are rendered now, so an offset API can page without extra bookkeeping.
retryvoidThe host should re-run the failed load.
selectedIdChangestring | number | nullEmits the new `selectedId` whenever it changes — the output half of `[(selectedId)]`.
expandedChangebooleanEmits the new `expanded` whenever it changes — the output half of `[(expanded)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Bound to the names this component actually exposes.

usage.htmlAngular
<sv-timeline
  [items]="data.items()"
  [loading]="data.loading()"
  [error]="data.error()"
  (retry)="data.reload()"
/>

Host class

Drive the three async inputs from your own fetch.

host.component.tsTypeScript
// This component reports its state but does not emit a query, so drive
// it from your own fetch and bind the three async inputs.
readonly items = signal<Row[]>([]);
readonly loading = signal(false);
readonly error = signal<SvDataError | null>(null);

async load(): Promise<void> {
  this.loading.set(true);
  this.error.set(null);
  try {
    this.items.set(await this.api.fetchRows());
  } catch (e) {
    this.error.set(toSvDataError(e));
  } finally {
    this.loading.set(false);
  }
}
  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `items`, `loading` and `error`.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Vertical, horizontal or alternating layouts.
  • Per-item icon, color and rich content.
  • Group by date for activity feeds.
  • `displayMode="status"` renders a Status / Progress Tracker — drive it with `activeIndex` (items before → complete, at → active, after → pending) or per-item `itemStatus`.
  • Rich-card CTAs emit `actionClick` (rendered as a link when `actionUrl` is set, otherwise a button).
  • `displayMode="interactive"` makes whole rows clickable — emits `itemClick` and highlights `selectedId` (two-way bindable).
  • `intent` (`chronology` / `process` / `feed`, default `auto`) is what the list MEANS, separate from what it looks like. It sets the list's accessible name ("Timeline" / "Progress tracker" / "Activity feed") and what `aria-current` says. `auto` keeps the old inference from `displayMode` and `dotStyle`, but an inferred chronology with an active item now says `aria-current="true"` and each list takes its intent's name — set `intent` to pin it.
  • `collapseAfter` (default `0`, off) shows the first N items and a count-naming "Show N more entries" control; `expanded` is two-way. Works in every layout, including `groupByDate`.
  • `showVariantIcon` (default `false`) draws a tick / cross / triangle / "i" inside `success` / `error` / `warning` / `info` dots that have no `icon` of their own, so the state is a shape as well as a hue.
  • Time-zone policy: `groupDate` is a label, never parsed. Consecutive items sharing it are grouped; resolve the day in the zone you want displayed before passing it.

Best practices

  • Do: Order items chronologically and keep each entry to a scannable line or two.
  • Do: Group long histories by date (`groupByDate`) for feed-style views.
  • Do: Resolve `groupDate` in the zone you want displayed and pass the finished label (`'Tue 14 May'`, `'Yesterday'`). The component never derives a day from `timestamp` — 23:30 in Berlin is Wednesday in Auckland — so the zone is the host's decision, which is the only place it can be made correctly.
  • Do: Set `collapseAfter` on a history that can grow without bound, and bind `[(expanded)]` if the reader should come back to it expanded.
  • Tip: Use color/icon to encode event type consistently.
  • Do: Set `intent` when the list is a process or a feed. It decides what a screen reader is told — `aria-current="step"`, `aria-current="true"` or nothing — and it is the one thing `displayMode` should never have been deciding.
  • Don't: Don't reach for a numbered or ticked `dotStyle` to make something announce as a process. It used to work, by accident, and that accident is the defect: the dot is a picture and `intent` is the claim.

Accessibility

  • Ordered list semantics preserve sequence — an `<ol>` of `<li>`s, so the sequence is the DOM order and needs no off-screen table alongside it.
  • The dot has a text equivalent. `variant` reached the page as a hue alone, which meant a failed deploy and a successful one read identically (WCAG 1.4.1); a coloured dot is now `role="img"` named "Error", "Success" and so on, and the variants that carry no meaning stay silent.
  • Name the list with `ariaLabel` when a page has more than one. Every list used to be a hardcoded "Timeline", so a release feed and an audit log were indistinguishable in a landmark list. In the date-grouped layout each group's list is that name plus its date ("Deploy log, Today").
  • Icons are decorative; text carries the meaning.
  • Stepper modes expose each step's state (Completed / Current / Pending) to screen readers and mark the active step with `aria-current="step"`.
  • The pulsing ring on an active step stops under `prefers-reduced-motion` (`motion-reduce:animate-none`); the dot itself stays, because it is the state.
  • `intent` decides what the current item is announced as, and it used to be decided by the shape of the dot: `aria-current` was gated on a ticked or numbered `dotStyle`, so a genuine process drawn with plain dots announced **nothing**, and a plain chronology could be made to claim a step by changing a picture. A `process` says `step`, a `chronology` says `true`, and a `feed` says nothing at all — a log of events is not somewhere you are partway through.
  • CTA controls carry a descriptive label combining the action and the item title.
  • Interactive rows (`displayMode="interactive"`) are keyboard-focusable `role="button"` controls inside the list items — Enter/Space activate, and the selected row is marked `aria-pressed` — so the list keeps its semantics while every row is operable without a mouse.

Feedback on Timeline

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.