GrTimeline

Package: @feugene/granularitycoreGroup: data

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

Machine-translated from the Russian original, not yet reviewed. Read the original

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 daygroupBy 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 horizontallyorientation 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

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

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 virtualisationuseVirtualList 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.

Playground 7

Loading…

Code
<GrTimeline />

Install

npm i @feugene/granularity

Import

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

API

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.

Examples 4

Basic

  1. Заказ создан
    Иванов И., через корзину
  2. Оплачен
    Картой •• 4242
  3. Передан в доставку
    Трек-номер RU8412093
  4. Вручение получателю

Basic
<script setup lang="ts">
import { GrTimeline, GrTimelineItem } from '@feugene/granularity'
</script>

<template>
  <div class="max-w-md">
    <GrTimeline>
      <GrTimelineItem
        time="10:24"
        datetime="2026-08-12T10:24"
        title="Заказ создан"
        description="Иванов И., через корзину"
        tone="primary"
      />
      <GrTimelineItem
        time="11:03"
        datetime="2026-08-12T11:03"
        title="Оплачен"
        description="Картой •• 4242"
        tone="success"
      />
      <GrTimelineItem
        time="14:47"
        datetime="2026-08-12T14:47"
        title="Передан в доставку"
        description="Трек-номер RU8412093"
        tone="info"
      />
      <GrTimelineItem
        time="ожидается"
        title="Вручение получателю"
        tone="neutral"
        pending
      />
    </GrTimeline>
  </div>
</template>

Layouts

  1. Сборка запущена
  2. Тесты пройдены
  3. Задеплоено на stage
  4. Ждём approve

Layouts
<script setup lang="ts">
import { ref } from 'vue'
import { GrSegmented, GrTimeline, GrTimelineItem } from '@feugene/granularity'

type Layout = 'stacked' | 'time' | 'alternate' | 'horizontal'

const layout = ref<Layout>('time')

const options = [
  { value: 'stacked', label: 'Одна ось' },
  { value: 'time', label: 'Колонка времени' },
  { value: 'alternate', label: 'Чередование' },
  { value: 'horizontal', label: 'Горизонталь' },
]

const events = [
  { at: '09:15', title: 'Сборка запущена', tone: 'primary' as const },
  { at: '09:22', title: 'Тесты пройдены', tone: 'success' as const },
  { at: '09:24', title: 'Задеплоено на stage', tone: 'info' as const },
  { at: '09:40', title: 'Ждём approve', tone: 'warning' as const, pending: true },
]
</script>

<template>
  <div class="grid gap-5">
    <GrSegmented v-model="layout" :options="options" size="sm" />

    <GrTimeline
      :items="events"
      item-key="at"
      :layout="layout === 'horizontal' ? 'stacked' : layout"
      :orientation="layout === 'horizontal' ? 'horizontal' : 'vertical'"
    >
      <template #item="{ item }">
        <GrTimelineItem
          :time="item.at"
          :title="item.title"
          :tone="item.tone"
          :pending="item.pending"
        />
      </template>
    </GrTimeline>
  </div>
</template>

Grouped

12 августа · 2

  1. Изменила права роли «Менеджер»
    Петрова А.
  2. Пригласил пользователя
    Иванов И.

11 августа · 2

  1. Ротация ключей API
    Система
  2. Удалил проект «Архив 2024»
    Сидоров П.

Порядок задаёт приложение: groupBy только режет набор на группы и не сортирует его.

Grouped
<script setup lang="ts">
import { GrBadge, GrTimeline, GrTimelineItem } from '@feugene/granularity'

interface AuditEvent {
  id: number
  day: string
  at: string
  actor: string
  action: string
  tone: 'neutral' | 'success' | 'warning' | 'danger'
}

const events: AuditEvent[] = [
  { id: 1, day: '12 августа', at: '18:02', actor: 'Петрова А.', action: 'Изменила права роли «Менеджер»', tone: 'warning' },
  { id: 2, day: '12 августа', at: '11:41', actor: 'Иванов И.', action: 'Пригласил пользователя', tone: 'success' },
  { id: 3, day: '11 августа', at: '20:15', actor: 'Система', action: 'Ротация ключей API', tone: 'neutral' },
  { id: 4, day: '11 августа', at: '09:03', actor: 'Сидоров П.', action: 'Удалил проект «Архив 2024»', tone: 'danger' },
]
</script>

<template>
  <div class="max-w-lg">
    <GrTimeline :items="events" item-key="id" group-by="day" density="compact">
      <template #group="{ group, items }">
        {{ group }} · {{ items.length }}
      </template>

      <template #item="{ item }">
        <GrTimelineItem :time="item.at" :tone="item.tone">
          <template #title>
            {{ item.action }}
          </template>
          <template #description>
            {{ item.actor }}
          </template>
        </GrTimelineItem>
      </template>
    </GrTimeline>

    <p class="mt-4 text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
      Порядок задаёт приложение: <GrBadge size="xs">groupBy</GrBadge> только режет набор на группы и не сортирует его.
    </p>
  </div>
</template>

Narrow

  1. Списание за подписку «Расширенный доступ» на месяц
  2. Пополнение с карты •• 4417 через платёжный шлюз
  3. Возврат по отменённой операции от 11 августа

Narrow
<script setup lang="ts">
import { ref } from 'vue'

import { GrSegmented, GrTimeline, GrTimelineItem } from '@feugene/granularity'

/**
 * Ширина контейнера, а не окна: сжатие считается по доступному месту, поэтому
 * увидеть его можно не трогая размер браузера.
 *
 * Заголовки намеренно длинные и с `truncate`: пока трек ленты держал минимум по
 * содержимому, усечение не срабатывало — усекать было нечего, колонка раздавалась
 * под текст и выносила строку за край.
 */
const width = ref('260')
const widths = [
  { value: '260', label: '260px' },
  { value: '320', label: '320px' },
  { value: '480', label: '480px' },
]

const events = [
  { id: 1, at: '10:24', title: 'Списание за подписку «Расширенный доступ» на месяц', tone: 'neutral' as const },
  { id: 2, at: '11:02', title: 'Пополнение с карты •• 4417 через платёжный шлюз', tone: 'success' as const },
  { id: 3, at: '14:47', title: 'Возврат по отменённой операции от 11 августа', tone: 'warning' as const },
]
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented v-model="width" :options="widths" size="sm" class="justify-self-start" />

    <div
      data-demo-narrow-box
      class="rounded-[var(--gr-radius-md)] border border-dashed border-[var(--gr-brd)] p-3"
      :style="{ width: `${width}px`, maxWidth: '100%' }"
    >
      <GrTimeline layout="time" density="compact">
        <GrTimelineItem
          v-for="event in events"
          :key="event.id"
          :time="event.at"
          :tone="event.tone"
        >
          <template #title>
            <span class="block truncate">{{ event.title }}</span>
          </template>
        </GrTimelineItem>
      </GrTimeline>
    </div>
  </div>
</template>

Accessibility

APG pattern

Full keyboard contract of the package

Component documentationAll components