Timeline
Free9 premium variations: horizontal, alternate zigzag, numbered stepper, check stepper, activity feed, status tracker, rich card, date-grouped and interactive (clickable rows).
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 demoMore examples
Activity feed grouped by date
<sv-timeline [items]="events" displayMode="feed" [groupByDate]="true" />
Horizontal stepper
<sv-timeline [items]="steps" layout="horizontal" [activeIndex]="2" />
Rich cards with CTA handler
<sv-timeline [items]="releases" displayMode="rich" (actionClick)="openRelease($event)" />
Interactive (clickable rows)
<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.
<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.
<!-- 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.
<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
| Name | Type | Default | Description |
|---|---|---|---|
| items | readonly TimelineItem[] | [] | Array of timeline events to render. |
| layout | TimelineLayout | 'left' | Structural layout direction. |
| size | TimelineSize | 'md' | Component size preset. |
| showConnector | boolean | true | Show connector lines between dots. |
| customClass | string | '' | Additional CSS classes on the root element. |
| ariaLabel | string | '' | 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`. |
| dotStyle | TimelineDotStyle | 'dot' | Dot rendering style. |
| displayMode | TimelineDisplayMode | 'default' | Content display mode. |
| intent | TimelineIntent | 'auto' | 'auto' | What the list means. `'auto'` infers it from `displayMode`. |
| activeIndex | number | -1 | Zero-based index of the currently active step. Items before → `complete`; this item → `active`; after → `pending`. Defaults to `-1` (uses each item's own `itemStatus`). |
| groupByDate | boolean | false | Group 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`. |
| collapseAfter | number | 0 | Show 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. |
| expanded | boolean (two-way) | false | Whether 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. |
| horizontalAlign | TimelineHorizontalAlign | 'alternate' | Content placement for `layout='horizontal'`. |
| showVariantIcon | boolean | false | Draw 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. |
| loading | boolean | false | True 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. |
| error | SvDataError | string | null | null | Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| emptyMessage | string | 'No events to show' | Copy for the empty state. |
| hasOlder | boolean | false | Older events exist further back in history. |
| loadingOlder | boolean | false | True while the previous page of history is being fetched. |
| hasNewer | boolean | false | Newer events exist — for a feed that is still being written to. |
| loadingNewer | boolean | false | True while newer events are being fetched. |
| selectedId | string | number | null (two-way) | null | Currently selected item id (two-way). Use `[(selectedId)]` with `displayMode="interactive"` for a selectable timeline. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| itemClick | TimelineItem | Emitted when an interactive row is activated (click / Enter / Space). Only fires when `displayMode='interactive'`. |
| actionClick | TimelineItem | Emitted when a `rich`-card CTA button is activated. Fires alongside any `actionUrl` navigation so you can handle the action in code. |
| loadMore | TimelineLoadMoreEvent | The user asked for another page. `direction` says which end: `'older'` for history below, `'newer'` for a feed that has moved on above. |
| retry | void | The host should re-run the failed load. |
| expandedChange | boolean | Emits the new `expanded` whenever it changes — the output half of `[(expanded)]`. |
| selectedIdChange | string | number | null | Emits 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
| Name | Type | Default | Description |
|---|---|---|---|
| items | readonly TimelineItem[] | [] | Array of timeline events to render. |
| layout | TimelineLayout | 'left' | Structural layout direction. |
| size | TimelineSize | 'md' | Component size preset. |
| showConnector | boolean | true | Show connector lines between dots. |
| customClass | string | '' | Additional CSS classes on the root element. |
| ariaLabel | string | '' | 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. |
| dotStyle | TimelineDotStyle | 'dot' | Dot rendering style. |
| displayMode | TimelineDisplayMode | '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). |
| intent | TimelineIntent | 'auto' | 'auto' | What the list means. `'auto'` infers it from `displayMode`. |
| selectedId | string | number | null (two-way) | null | Currently selected item id — used by `displayMode='interactive'` to highlight the active row. Two-way bindable; also updated automatically when an item is activated. |
| activeIndex | number | -1 | Zero-based index of the currently active step. Items before → `complete`; this item → `active`; after → `pending`. Defaults to `-1` (uses each item's own `itemStatus`). |
| groupByDate | boolean | false | Group 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. |
| collapseAfter | number | 0 | Show 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. |
| expanded | boolean (two-way) | false | Whether 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. |
| horizontalAlign | TimelineHorizontalAlign | 'alternate' | Content placement for `layout='horizontal'`. |
| showVariantIcon | boolean | false | Draw 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. |
| loading | boolean | false | True 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. |
| error | SvDataError | string | null | null | Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| emptyMessage | string | 'No events to show' | Copy for the empty state. |
| hasOlder | boolean | false | Older events exist further back in history. |
| loadingOlder | boolean | false | True while the previous page of history is being fetched. |
| hasNewer | boolean | false | Newer events exist — for a feed that is still being written to. |
| loadingNewer | boolean | false | True while newer events are being fetched. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| actionClick | TimelineItem | Emitted 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. |
| itemClick | TimelineItem | Emitted when an item is activated in `displayMode='interactive'` (click, Enter or Space on the item button). |
| loadMore | TimelineLoadMoreEvent | The 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. |
| retry | void | The host should re-run the failed load. |
| selectedIdChange | string | number | null | Emits the new `selectedId` whenever it changes — the output half of `[(selectedId)]`. |
| expandedChange | boolean | Emits 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.
<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.
// 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.