Ansora UI
Components

Event timeline

A read-only, ordered record of what happened to one record — a pre-formatted time, a title, who did it and an optional body, grouped by consecutive day labels, in a scroll region of its own.

conversationdata

Install

pnpm dlx shadcn@4.23.0 add @ansora/event-timeline

Examples

Usage

Read-only, and it owns no time. at arrives ALREADY FORMATTED, because the four products that derived this component want four different wordings of the same instant: the receptionist's finished call is minute-scale, video's merged clinical + technical log is second-resolution, reservations wants a date beside a time, and after-care's screen is read by a clinician who wants „2. 8.“ and by a patient who wants „včera“. Format it at the call site through @ansora/core — formatTime, formatDateTime, formatDayMonthLong — never by hand and never with an ASCII space before a unit.

dateTime is the machine-readable half, and it is kept beside the pre-formatted string on purpose: at is a bet (the inventory behind this item says so), and this is what stops four products' worth of call sites having to reconstruct an instant out of Czech prose if the bet is lost. It takes an ISO instant (Date#toISOString()) or an ISO duration (formatIsoDuration), because <time> accepts both — and the duration form is what after-care's most meaningful entry needs: „3 dny bez odezvy“ is a SPAN, and the absence is the event. That is why an absence needs no prop of its own here. Omit dateTime and the time renders as a plain <span>: a <time> with no datetime attribute must carry machine-readable text, and „včera“ is not.

Grouping is a consecutive RUN of one groupLabel, never a sort and never a group-by. Entries carrying the same label back to back form one block with one caption and one <ol>; a label that reappears after a different one opens a second block, which is the honest rendering of the order you handed over. Order is yours — on a record screen the order IS the record — so the component never sorts, reverses or de-duplicates. The caption is a paragraph, not a heading: a week-scale history is dozens of captions, and as headings they are dozens of transient outline entries naming no place a rotor user can navigate back to. Each day's <ol> still contributes its item count, which is how a screen-reader user learns how much happened that day.

Required, with no default: items, label (the invisible name of the scroll region — a record screen carries more than one scrollable group, and an unnamed one is anonymous in the rotor) and empty as ONE object ({ title, description }), so a half-supplied empty state is a compile error rather than a blank card. Every user-facing word is yours; the component ships no Czech string of its own.

Per entry: id, at and title are required. actor is optional but is never decoration — it is the load-bearing field of reservations' Detail rezervace, the screen that exists to settle „kdo to zrušil“, so it renders at the weight of the title rather than as a muted footnote, and role is required with it („zrušila recepce“ and „zrušila pacientka“ are different answers). status is one object — { label, tone? } — never a bare tone: a tone alone would paint the mark and nothing else, which is state carried by colour. The label goes through StatusPill, and there is no green in the map. pending is an object with a required label for the same reason: the visual carrier of „still happening“ is a HOLLOW mark, so a boolean would let you switch on a state that has no word anywhere. The label renders visibly (not sr-only — a sighted reader of a greyscale screen has exactly the same problem) and the entry is aria-busy.

No live mode, no auto-scroll, no fetching. The receptionist's appending call trace wants the tail and after-care's reader is scrolling history; a component that owned the scroll position would fight one of them. A live append is pending on the last entry and nothing else.

It brings its own scroll region — focusable, named, with an inset focus ring (the @ansora/data-table precedent, not the SectionCard one), so a long history stays reachable by keyboard when the component is used standalone. Two consequences. Give it a height: className="max-h-96", or flex-1 inside a column that has a definite one — with no constraint the region simply grows and never scrolls, and nothing throws. And inside a SectionCard, pass that card no scroll prop, or the history ends up inside two nested scroll regions.

import { formatDayMonthLong, formatTime } from "@ansora/core"
import { EventTimeline } from "@/components/ansora/event-timeline"
import { SectionCard } from "@/components/ui/ansora/section-card"

const zrusena = new Date(2026, 7, 5, 14, 32)

<SectionCard title="Průběh rezervace">
  <EventTimeline
    className="max-h-96 px-(--card-p)"
    label="Průběh rezervace"
    empty={{ title: "Zatím se nic nestalo", description: "Historie se objeví po první změně." }}
    items={[
      {
        id: "zrusena",
        groupLabel: formatDayMonthLong(zrusena),
        at: formatTime(zrusena),
        dateTime: zrusena.toISOString(),
        title: "Rezervace zrušena",
        actor: { name: "Marie Horáková", role: "Recepce" },
        status: { label: "Zrušeno", tone: "critical" },
      },
    ]}
  />
</SectionCard>

Files

Dependencies

  • @ansora/base
  • @ansora/empty-state
  • @ansora/status-pill

On this page