opsinjs
ComponentsHealth data display

TimelineEntry

One dated event in a vertical history, with a neutral rail and marker, the time written the way people say it, and any clinical status shown as a nested pill.

Preview

Theme
Density
Text
Status
timeline-entry · base base · style base-lyraOpen under the product theme

Installation

pnpm dlx shadcn@latest add @opsinjs/timeline-entry

The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.

Usage

import { TimelineEntry } from "@/components/ui/timeline-entry"
<ol>
  <TimelineEntry
    when="2026-03-14T08:12:00+00:00"
    now={new Date().toISOString()}
    title="Repeat prescription issued"
    status="watch"
    isLast
  >
    Issued by the example clinic.
  </TimelineEntry>
</ol>

When to use it

Use it when

  • Showing one recorded event in a history the product lays out: a prescription issued, a test booked, a note added.
  • Placing a single dated entry on a vertical rail, where the product owns the list around it.
  • A row that needs the recorded time in words, with an optional status beside the event.

Do not use it when

  • You want one component to own the whole history, its ordering and its data model.Use card instead.
  • You are charting one reading as it moves over time rather than listing distinct events.Use trend-sparkline instead.
  • You only need to say when a single thing happened, with no rail, marker or event around it.Use relative-time instead.
  • You are recording medicine doses taken and missed, which has its own shape.Use dose-tracker instead.
  • You need the reader to write down what happened rather than read a recorded event.Use log-sheet instead.

Clinical meaning

Asserts. That the named event was recorded at the given instant, and nothing more. A gap between two entries is not evidence that nothing happened in it.

Never read as. A diagnosis, a verdict, or a guarantee that the history is complete or current. The entry never triages, diagnoses or advises, and every word in it is the product's.

Colour axis. Neither, on the entry. The rail and the marker are neutral chrome. A supplied status is delegated to a nested StatusPill, which carries the status axis with a word and a glyph. See The two colour axes.

Thresholds. The product owns them all. The entry holds no threshold, no staleness boundary and no event text. Uncertainty, staleness and missing data covers why it never guesses whether a recorded time is too old to trust, and Trends and change covers why one entry is one event, not a direction.

Anatomy

  1. TimelineEntryThe root, a single list item. It takes colour from neither axis.Controlled by when
  2. TimelineEntry.RailThe neutral column holding the marker and, unless this is the last entry, the connector.Controlled by isLast
  3. TimelineEntry.MarkerThe dot on the rail: a muted fill and a hairline. Never a status colour.
  4. TimelineEntry.TimeThe recorded time, drawn by RelativeTime with the event word fixed at recorded.Controlled by now
  5. TimelineEntry.TitleWhat happened, in the reader's words, with any status pill beside it.Controlled by title
  6. StatusPillThe level the product assigned, at size sm. The only part with an axis colour.Controlled by status
  7. TimelineEntry.BodyThe product's own detail beneath the title, in muted text.Controlled by children
  • TimelineEntry 1A li element. Neither data-status nor data-category.
    • TimelineEntry.Rail 1aria-hidden.
      • TimelineEntry.Marker 1
    • TimelineEntry.Time 1Wraps a RelativeTime.
    • TimelineEntry.Title 1
      • StatusPill 0..1Stamps data-status. Absent when no status was supplied.
    • TimelineEntry.Body 0..1Absent when no children are given.

Examples

A history on one rail

Every row names and dates its event, the connector runs to the last entry and stops, and one row carries a status the product assigned.

Theme
Density
Text
Status
timeline-entry-in-a-history · base base · style base-lyraOpen under the product theme

The status rides the pill

Two entries alike but for status. The rail, the marker and the entry stay neutral, and status changes only the pill beside the title.

Theme
Density
Text
Status
timeline-entry-with-a-status · base base · style base-lyraOpen under the product theme

Content guidelines

Name the event in the title as something that happened: Repeat prescription issued, Blood test booked. A bare noun leaves the reader guessing whether it was done, due, or cancelled. Let any status stay on the pill.

Do

"Repeat prescription issued" with the recorded time beneath it and the status on a pill.

Don’t

The whole entry tinted amber to flag the event. A status carried by colour alone dies in greyscale.

Accessibility

Audited against WCAG 2.2 AA in a source pass and a rendered pass. The rendered pass (axe, target size, focus visible, reflow at 320px) and the source pass against the rubric were both clean. This audit is author run, not an independent review.

  • The rail and the marker carry aria-hidden, so a screen reader meets the time, the title, any status word and the body in reading order.
  • The recorded time is the system RelativeTime, a semantic time element, so the exact instant stays in the accessibility tree when only the phrase shows.
  • Nothing in the entry is focusable, so keyboard, focus and target size do not apply.

Read this before you rely on it.

  • The marker and the connector are decorative chrome under aria-hidden, so they are exempt from Non-text Contrast (1.4.11). They use role tokens anyway.
  • The root renders a bare li, so the product must supply the ol or ul around it for list semantics to hold. On Safari with VoiceOver a list-style: none list can drop its list role, a platform quirk shared by every list based component here rather than a fault in this one.
KeyActionNotes
TabSkips the entryNothing here is focusable, so a long history costs a keyboard user no stops.
Shift+TabSkips the entrySame in reverse. The time, the title and the body are reached by reading.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

API reference

Prop

Type

Generated from TimelineEntryProps in registry/bases/base/timeline-entry.tsx.

when is ISO 8601 with an offset, the contract RelativeTime enforces on its at. now is required because this component never reads the clock, so read the clock once per screen and pass one value to every entry. The event word is fixed and no locale is forwarded, so a product needing either composes RelativeTime directly.

  • RelativeTime is the recorded time alone, with no event or rail around it.
  • Card is a bounded block around related content; an entry can sit inside one.
  • TrendSparkline is one reading moving over time, where this lists distinct events.

On this page