GrTimeline
Shows a chronological feed of events with an axis, markers and day headings.
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 —
groupByplaces 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 —
orientationfor a progress scale instead of a column.
When to take something else
| Need | Take |
|---|---|
| The order does not matter, the rows are uniform | GrList |
| The data is tabular | GrDataTable |
| The steps of a wizard with moves forward and back | GrTabs |
| A moment relative to now | GrRelativeTime |
| How a quantity changed | GrChartLine |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrTimeline emptyText="No events yet" />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 ispending— a hollow point and a dashed stretch leading to it, the road not travelled. Themarkerslot 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 - Order placedPaid by card ending 6411
- Packed at the Lisbon warehouse
- Handed to the courierTracking number CTT RH4471 9902 PT
- Out for deliveryBetween 09:00 and 13:00
- Delivered
An audit log by day
A homogeneous set goes in as
items— only then can the line group it.group-bycuts the events by day as they came, with no sorting and no date parsing, and thegroupslot words a heading atgroup-heading-level3.layout="time"stands the times in a column,density="compact"keeps an event to a line,loadingputs placeholders in the rows, and theemptyslot answers a filter with nothing in it.Audit log Today · 3 events
- Mina Ortiz signed in from Lisbon
- Daniel Kim refunded €48.00 on INV-2026-0411
- System blocked 5 sign-ins from an unknown device
Yesterday · 2 events
- Grace Wu made Jonas Weber an admin
- 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 isvariant="outlined"— a hollow point on a solid road; what is still to come ispending, with the road to it dashed.Roadmap 2026–2027 - 4.0 · Multi-currency
- 4.1 · SEPA Instant payouts
- 4.2 · Prorated seats
- 4.3 · Usage-based plans · beta
- 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 ispending.How Northwind Billing grew - Two people, one spreadsheetInvoices for a Lisbon coworking, sent by hand
- The first 100 customersRecurring billing and card payments
- SEPA and VAT across the EUOffices in Lisbon and Dublin
- 1,000 businesses€40M billed through the platform in a year
- Revenue recognitionPlanned: the books close themselves
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
items | T[] | undefined | undefined | The 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. |
itemKey | string | ((item: T, index: number) => string | number) | undefined | undefined | The key of an item for `v-for`: the name of a field or a function. Without it — the index. |
groupBy | GrTimelineGroupBy<T> | undefined | undefined | The grouping: the name of a field or a function of the item. The heading of a group is the `#group` slot. |
groupHeadingLevel | 2 | 3 | 4 | 5 | 6 | undefined | undefined | The 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" | undefined | undefined | The layout: one axis, a column of time on the left or alternating sides. |
orientation | "horizontal" | "vertical" | undefined | undefined | The direction of the axis. A horizontal timeline is a scroller. |
density | "regular" | "compact" | undefined | undefined | The density of the vertical paddings of an item. |
loading | boolean | undefined | false | Loading is in progress: placeholders instead of the items, and the container is marked `aria-busy`. |
loadingRows | number | undefined | 3 | How many placeholder rows to show with `loading`. |
empty | boolean | undefined | undefined | The 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. |
emptyText | string | undefined | undefined | The text of the empty state. The `#empty` slot is stronger. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The 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. |
empty | any | The content of the empty state instead of the default text. |
loading | any | The 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 type { GrTimelineTone, GrTimelineDensity, GrTimelineLayout } from '@feugene/granularity/components/GrTimeline'Filedist/types/components/GrTimeline/grTimelineStyles.d.ts
type GrTimelineTone = GrTone;type GrTimelineDensity = typeof GR_TIMELINE_DENSITIES[number];type GrTimelineLayout = typeof GR_TIMELINE_LAYOUTS[number];type GrTimelineOrientation = typeof GR_TIMELINE_ORIENTATIONS[number];type GrTimelineMarkerVariant = typeof GR_TIMELINE_MARKER_VARIANTS[number];Filedist/types/components/GrTimeline/timelineGroups.d.ts
type GrTimelineGroupBy<T> = string | ((item: T, index: number) => string);
export interface GrTimelineEntry<T> {interface GrTimelineEntry<T> {
item: T;
index: number;
}interface GrTimelineGroup<T> {
key: string;
entries: GrTimelineEntry<T>[];
}Filedist/types/components/GrTimeline/GrTimeline.vue.d.ts
interface GrTimelineProps<TItem = unknown> {
items?: TItem[];
itemKey?: string | ((item: TItem, index: number) => string | number);Filedist/types/components/GrTimeline/GrTimelineItem.vue.d.ts
interface GrTimelineItemProps {
time?: string;
datetime?: string;
title?: string;
description?: string;
tone?: GrTimelineTone;
variant?: GrTimelineMarkerVariant;
pending?: boolean;
}Filedist/types/components/GrTimeline/config.d.ts
const grTimelineConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTimeline">;Filedist/types/components/GrTimeline/grTimelineContext.d.ts
type GrTimelineHeadingLevel = typeof GR_TIMELINE_HEADING_LEVELS[number];Filedist/types/components/GrTimeline/safelist.d.ts
const grTimelineSafelist: string[];Filedist/types/components/GrTimeline/defaults.d.ts
interface GrTimelineConfigurableProps {
layout: GrTimelineLayout;
orientation: GrTimelineOrientation;
density: GrTimelineDensity;
groupHeadingLevel: GrTimelineHeadingLevel;
}Accessibility
APG pattern
—
Full contract of the package: docs/keyboard.md ↗
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:
<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
layout | When |
|---|---|
stacked | the default: one axis on the left, the time as part of the content |
time | an audit log: the timestamps stand in a column and are read as a column |
alternate | a 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 inGrList; - there is no virtualisation —
useVirtualListis 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-motionbranch 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: neitheraria-current, nor navigation, nor validation per step.
Props in this example