GrTimeline

  • core
  • data
  • API · 11 props
  • ⌨ keys

Shows a chronological feed of events with an axis, markers and day headings.

import { GrTimeline } from '@feugene/granularity/components/GrTimeline'

Overview

When to take it

  • the events run in time — the history of an order, an audit log, the status of a delivery: the order is the meaning;
  • the events are grouped by day — groupBy places the headings without requiring the data to be prepared;
  • the process is not finished — an unfinished step is drawn with a dashed line rather than looking done;
  • the feed is built horizontally — orientation for a progress scale instead of a column.

When to take something else

NeedTake
The order does not matter, the rows are uniformGrList
The data is tabularGrDataTable
The steps of a wizard with moves forward and backGrTabs
A moment relative to nowGrRelativeTime
How a quantity changedGrChartLine

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • Where the order is now

    Every step is different, so the line is written item by item with GrTimelineItem. Each mark is a <time datetime>, machine-readable. What has not happened yet is pending — a hollow point and a dashed stretch leading to it, the road not travelled. The marker slot puts an icon where a status is worth more than a dot, and the default slot carries the tracking link.

    Order BV-20418 · Oak writing deskArrives Thursday, 9 October
    1. Order placed
      Paid by card ending 6411
    2. Packed at the Lisbon warehouse
    3. Handed to the courier
      Tracking number CTT RH4471 9902 PT
    4. Out for delivery
      Between 09:00 and 13:00
    5. Delivered
  • An audit log by day

    A homogeneous set goes in as items — only then can the line group it. group-by cuts the events by day as they came, with no sorting and no date parsing, and the group slot words a heading at group-heading-level 3. layout="time" stands the times in a column, density="compact" keeps an event to a line, loading puts placeholders in the rows, and the empty slot answers a filter with nothing in it.

    Audit log

    Today · 3 events

    1. Mina Ortiz signed in from Lisbon
    2. Daniel Kim refunded €48.00 on INV-2026-0411
    3. System blocked 5 sign-ins from an unknown device

    Yesterday · 2 events

    1. Grace Wu made Jonas Weber an admin
    2. Daniel Kim issued 42 invoices for October
  • A public roadmap

    orientation="horizontal" turns the axis left to right and the line into a scroller that takes keyboard focus, so it can be scrolled without a mouse. A release in beta is variant="outlined" — a hollow point on a solid road; what is still to come is pending, with the road to it dashed.

    Roadmap 2026–2027
    1. 4.0 · Multi-currency
    2. 4.1 · SEPA Instant payouts
    3. 4.2 · Prorated seats
    4. 4.3 · Usage-based plans · beta
    5. 5.0 · Revenue recognition
  • A company story for an «About» page

    layout="alternate" puts the events on both sides of the axis; on a narrow screen it folds into one side by itself, because a two-sided line at 320px cannot be read. The milestone that is still a plan is pending.

    How Northwind Billing grew
    1. Two people, one spreadsheet
      Invoices for a Lisbon coworking, sent by hand
    2. The first 100 customers
      Recurring billing and card payments
    3. SEPA and VAT across the EU
      Offices in Lisbon and Dublin
    4. 1,000 businesses
      €40M billed through the platform in a year
    5. Revenue recognition
      Planned: the books close themselves

API generated from the package · cannot drift

Props

PropTypedefaultDescription
itemsT[] | undefinedundefinedThe data of the timeline. With it the items are drawn by the `#item` slot — and only that way does the timeline know the composition of the set, which is to say it can group it.
itemKeystring | ((item: T, index: number) => string | number) | undefinedundefinedThe key of an item for `v-for`: the name of a field or a function. Without it — the index.
groupByGrTimelineGroupBy<T> | undefinedundefinedThe grouping: the name of a field or a function of the item. The heading of a group is the `#group` slot.
groupHeadingLevel2 | 3 | 4 | 5 | 6 | undefinedundefinedThe level of the heading of a group: the timeline is obliged to fit into the structure of the headings of the page.
layout"stacked" | "time" | "alternate" | undefinedundefinedThe layout: one axis, a column of time on the left or alternating sides.
orientation"horizontal" | "vertical" | undefinedundefinedThe direction of the axis. A horizontal timeline is a scroller.
density"regular" | "compact" | undefinedundefinedThe density of the vertical paddings of an item.
loadingboolean | undefinedfalseLoading is in progress: placeholders instead of the items, and the container is marked `aria-busy`.
loadingRowsnumber | undefined3How many placeholder rows to show with `loading`.
emptyboolean | undefinedundefinedThe timeline is empty. By default it works this out itself — by the data or by the slot; the prop is needed where the consumer knows better.
emptyTextstring | undefinedundefinedThe text of the empty state. The `#empty` slot is stronger.

Slots

SlotTypeDescription
defaultanyThe items of the timeline (`GrTimelineItem`) when no data is passed by the prop.
item{ item: T; index: number; }An item in the data mode.
group{ group: string; items: T[]; }The heading of a group. By default — the key itself.
emptyanyThe content of the empty state instead of the default text.
loadinganyThe placeholders of the loading instead of the default rows.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrTimelineTone, GrTimelineDensity, GrTimelineLayout } from '@feugene/granularity/components/GrTimeline'

Filedist/types/components/GrTimeline/grTimelineStyles.d.ts

GrTimelineTonetype
type GrTimelineTone = GrTone;
GrTimelineDensitytype
type GrTimelineDensity = typeof GR_TIMELINE_DENSITIES[number];
GrTimelineLayouttype
type GrTimelineLayout = typeof GR_TIMELINE_LAYOUTS[number];
GrTimelineOrientationtype
type GrTimelineOrientation = typeof GR_TIMELINE_ORIENTATIONS[number];
GrTimelineMarkerVarianttype
type GrTimelineMarkerVariant = typeof GR_TIMELINE_MARKER_VARIANTS[number];

Filedist/types/components/GrTimeline/timelineGroups.d.ts

GrTimelineGroupBytype
type GrTimelineGroupBy<T> = string | ((item: T, index: number) => string);
export interface GrTimelineEntry<T> {
GrTimelineEntryinterface
interface GrTimelineEntry<T> {
    item: T;
    index: number;
}
GrTimelineGroupinterface
interface GrTimelineGroup<T> {
    key: string;
    entries: GrTimelineEntry<T>[];
}

Filedist/types/components/GrTimeline/GrTimeline.vue.d.ts

GrTimelinePropsinterface
interface GrTimelineProps<TItem = unknown> {
    items?: TItem[];
    itemKey?: string | ((item: TItem, index: number) => string | number);

Filedist/types/components/GrTimeline/GrTimelineItem.vue.d.ts

GrTimelineItemPropsinterface
interface GrTimelineItemProps {
    time?: string;
    datetime?: string;
    title?: string;
    description?: string;
    tone?: GrTimelineTone;
    variant?: GrTimelineMarkerVariant;
    pending?: boolean;
}

Filedist/types/components/GrTimeline/config.d.ts

grTimelineConfigconst
const grTimelineConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTimeline">;

Filedist/types/components/GrTimeline/grTimelineContext.d.ts

GrTimelineHeadingLeveltype
type GrTimelineHeadingLevel = typeof GR_TIMELINE_HEADING_LEVELS[number];

Filedist/types/components/GrTimeline/safelist.d.ts

grTimelineSafelistconst
const grTimelineSafelist: string[];

Filedist/types/components/GrTimeline/defaults.d.ts

GrTimelineConfigurablePropsinterface
interface GrTimelineConfigurableProps {
    layout: GrTimelineLayout;
    orientation: GrTimelineOrientation;
    density: GrTimelineDensity;
    groupHeadingLevel: GrTimelineHeadingLevel;
}

Accessibility

APG pattern

—

Usage from the library prose

Two ways of presenting, as in GrList

A heterogeneous feed is written as items: GrTimelineItem one after another. A uniform set is handed over with a prop — and only then does the feed know the composition of the data, that is, it can group it:

vue
<GrTimeline :items="events" item-key="id" group-by="day">
  <template #group="{ group, items }">{{ group }} · {{ items.length }}</template>
  <template #item="{ item }">
    <GrTimelineItem :time="item.at" :title="item.title" :tone="item.tone" />
  </template>
</GrTimeline>

groupBy only cuts the set into groups: the order of the events and the order of the groups stay as they arrived. There is no sorting and no parsing of dates inside — otherwise the component would get time zones and local formats, which the library does not have. What counts as a day is decided by the application: with a string in a field or with a function.

The semantics

A flat feed is an <ol>: the events are ordered in time, and a screen reader is obliged to announce that. A group is a <section> with an <h3> heading (the level is set by groupHeadingLevel) and an <ol> of its own inside. The heading of a day is not made a list item: an <li> with a date would enter the set of events and would be announced as one of them.

The axis and the markers are decorative (aria-hidden) — the meaning is carried by the text of the event. The tone of a marker says nothing beyond what is written in words: colour here reinforces rather than replaces.

The timestamp is a <time>; with datetime it is machine-readable as well.

The layouts
layoutWhen
stackedthe default: one axis on the left, the time as part of the content
timean audit log: the timestamps stand in a column and are read as a column
alternatea presentational history feed — the sides alternate

orientation="horizontal" turns the axis left to right, the events become columns, and the feed becomes a scroller (and gets tabindex="0": a scrolling block has to be reachable from the keyboard). The vertical layouts do not apply in it — a horizontal axis has neither a column of time nor sides — and in DEV the component warns about that.

alternate collapses into a one-sided feed on a narrow screen: a two-sided feed at 320px is unreadable, and leaving that to the consumer would mean leaving them a bug.

time behaves differently in a narrow column: the column of time stays at every width — the layout is taken for its sake — and it is the column of content that shrinks. Its width is set by the --gr-timeline-time-width hook (5.5rem by default), and in a really tight place it is worth reducing.

Truncating a long text is the consumer’s business: put a truncate (or a text-overflow of your own) on the heading. The feed can shrink for that: its flexible tracks are declared minmax(0, 1fr) rather than a bare 1fr. The difference is not obvious and deserves an explanation: 1fr is minmax(auto, 1fr), the minimum of such a track equals the min-content of the content, and with the white-space: nowrap of truncate that is the full width of the line. The track would spread to fit the text, there would be nothing to truncate, and the line would carry itself beyond the edge — that is, truncate would not work by construction.

Why the layouts are in CSS

The side of an item in alternate is an nth-child, and the end of the axis at the last event is a last-child. An item does not know its index and must not know it: registering the children in a context on onMounted would give an order dependent on the order of mounting and would fall apart on a v-if in the middle of the feed.

Hence the shape of the markup as well: the <li> is drawn by GrTimelineItem itself — in both modes, and in the data mode it stands inside the #item slot. The axis is a segment at every item rather than one long line: neighbouring segments join by themselves, and there is no need to measure the height of the list.

The marker

tone colours the dot with a tone role, variant="outlined" makes it hollow, and the #marker slot replaces the dot with anything at all — a status icon, the avatar of an author, the number of a step.

pending means the event has not happened yet: the dot is hollow, and the axis runs dashed starting from the segment leading to it — that is, both it and the item’s own segment are dashed. The dashing starts precisely from the leading one rather than from the following one: an unfinished event is usually the last, and its own segment is not visible at all — the dashing would turn out invisible in exactly the most frequent case.

The emptiness is determined by itself

If empty is not set, the feed decides for itself: in the data mode by the length of items, in the slot mode by whether there are meaningful nodes in the slot. The check does not come down to “the slot is not empty”: a v-for over an empty array leaves a fragment with no nodes, and a v-if leaves a comment, and neither of those is an item of the feed. Without that a feed with a v-for over empty data would draw an axis out of nothing instead of an explanation.

The prop overrides the automatic detection in both directions — it is needed where “empty” is decided by the server rather than by the markup. The text is changed with emptyText, and the look as a whole with the #empty slot.

loading shows loadingRows placeholder rows (three by default) built on GrSkeleton rather than a spinner over emptiness: the placeholders hold the height, and the appearance of the data does not shift the page.

Limits
  • there is no keyboard of its own — the feed shows rather than selects; interactive elements are put inside by the consumer, and the tab order is theirs;
  • an item is not clickable by itself — a clickable row is assembled with a nested GrLink/GrButton, as in GrList;
  • there is no virtualisation — useVirtualList is designed for a uniform row height, and events are heterogeneous; a thousand events in an interface is pagination rather than a window;
  • there is no appearance animation — movement requires a prefers-reduced-motion branch of its own, and the value here is decorative;
  • this is not GrSteps — a horizontal feed looks like a stepper but is not made of steps: neither aria-current, nor navigation, nor validation per step.