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
timeline-entry · base base · style base-lyraOpen under the product themeInstallation
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
cardinstead. - You are charting one reading as it moves over time rather than listing distinct events.Use
trend-sparklineinstead. - You only need to say when a single thing happened, with no rail, marker or event around it.Use
relative-timeinstead. - You are recording medicine doses taken and missed, which has its own shape.Use
dose-trackerinstead. - You need the reader to write down what happened rather than read a recorded event.Use
log-sheetinstead.
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
TimelineEntryThe root, a single list item. It takes colour from neither axis.Controlled bywhenTimelineEntry.RailThe neutral column holding the marker and, unless this is the last entry, the connector.Controlled byisLastTimelineEntry.MarkerThe dot on the rail: a muted fill and a hairline. Never a status colour.TimelineEntry.TimeThe recorded time, drawn by RelativeTime with the event word fixed at recorded.Controlled bynowTimelineEntry.TitleWhat happened, in the reader's words, with any status pill beside it.Controlled bytitleStatusPillThe level the product assigned, at size sm. The only part with an axis colour.Controlled bystatusTimelineEntry.BodyThe product's own detail beneath the title, in muted text.Controlled bychildren
- 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.
- TimelineEntry.Rail 1aria-hidden.
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.
timeline-entry-in-a-history · base base · style base-lyraOpen under the product themeThe 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.
timeline-entry-with-a-status · base base · style base-lyraOpen under the product themeContent 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
timeelement, 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 theolorularound it for list semantics to hold. On Safari with VoiceOver alist-style: nonelist can drop its list role, a platform quirk shared by every list based component here rather than a fault in this one.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the entry | Nothing here is focusable, so a long history costs a keyboard user no stops. |
| Shift+Tab | Skips the entry | Same in reverse. The time, the title and the body are reached by reading. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below 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.
Related
- 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.
RangeLegend
The key that names the tones a RangeBar draws, so a reader learns in one place what the reference band and each status colour mean. It asserts nothing itself.
GoalRing
A ring that shows how far a reading has come towards a goal the product set, paired with a text twin that states every figure, and rewarding no streak.