Skip to content

Timeline

A chronology: an ordered list whose children are entries, group headings and a now marker. The rail is not one line down the middle — each entry paints the half above its node and the half below, which is how a heading or a marker breaks the line by painting neither. Every layout is the same DOM on a different grid, and the scroll-driven reveal sits behind a support query with a complete static fallback underneath it.

  • Stable
  • React 19
  • Zero JS
  • 4 layouts, 4 rails
  • Scroll-driven CSS
  1. Order confirmed

    Card ending 4417 accepted.

  2. Packed

    Boxed at the Ñuñoa depot.

  3. Out for delivery

    Three stops ahead of yours.

  4. Estimated arrival

    Not yet left the last stop.

Two done, one current with a pulsing node, one upcoming on a dotted rail.

  1. Order confirmed

    Card ending 4417 accepted.

  2. Packed

    Boxed at the Ñuñoa depot.

  3. Out for delivery

    Three stops ahead of yours.

Code
<Timeline aria-label="Delivery status">
<TimelineItem
color="success"
state="done"
time="09:41"
dateTime="2026-08-21T09:41"
title="Order confirmed"
description="Card ending 4417 accepted."
/>
<TimelineItem color="success" state="done" time="11:20" title="Packed" … />
<TimelineItem color="info" state="current" time="14:05" title="Out for delivery" … />
</Timeline>
<ol class="wayra-timeline" aria-label="Usage timeline" data-size="md" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T09:41">09:41</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Order confirmed</span>
<p class="wayra-timeline-desc">Card ending 4417 accepted.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T11:20">11:20</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Packed</span>
<p class="wayra-timeline-desc">Boxed at the Ñuñoa depot.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="info" data-state="current">
<time class="wayra-timeline-time" datetime="2026-08-21T14:05">14:05</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Out for delivery</span>
<p class="wayra-timeline-desc">Three stops ahead of yours.</p>
</div>
</li>
</ol>

The root is an <ol>, not a <ul>: the order of the list is the order of the events, and an ordered list is what says so. Every slot is optional — an entry with nothing but a title still draws its node and its rail. dateTime is what turns the visible stamp into a real <time datetime>; without it the stamp is a plain <span>.

state fills the rail and shapes the node. It is a look and nothing else: a screen reader hears the entry’s text and no more, so words like “delivered” or “pending” have to be in that text as well. The library injects no such label — the same rule Radio follows for its group error.

state The rail The node The text
(none) Neutral hairline Solid dot in the role Ink
done Both halves in the role Solid dot in the role Ink
current The half above the node in the role Solid, with a pulsing ring Title takes the accent
upcoming Dotted Hollow, ringed Title and description muted

current is the only state with an animation, and it is the one animation the library replaces rather than removes: under prefers-reduced-motion the ring rests at the size and opacity it spends most of its cycle at, instead of vanishing.

The node is aria-hidden, so nothing that lives only inside it is announced. Put an icon in it, an image, an Avatar, or the version string of a changelog entry; leave it empty and it draws the plain dot.

  1. 09:00
    A dot

    No node given — the default.

  2. 09:20
    An icon

    On the tinted well its role gives it.

  3. 09:40
    Another icon

    Same lane, same box, same rail.

One lane for the whole list. A node that grew on its own would bend the rail.

Code
<TimelineItem color="success" node={<CheckIcon />} time="09:20" title="An icon" />
<ol class="wayra-timeline" aria-label="Mixed nodes" data-size="md" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-item" data-color="neutral">
<span class="wayra-timeline-time">09:00</span>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">A dot</span>
<p class="wayra-timeline-desc">No node given — the default.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="success">
<span class="wayra-timeline-time">09:20</span>
<span class="wayra-timeline-node" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M3.5 8.5 6.5 11.5 12.5 5"/></svg></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">An icon</span>
<p class="wayra-timeline-desc">On the tinted well its role gives it.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="info">
<span class="wayra-timeline-time">09:40</span>
<span class="wayra-timeline-node" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 4.5h8v7h-8zM9.5 7h3l2 2.5v2h-5z"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="11.5" cy="12" r="1.2"/></svg></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Another icon</span>
<p class="wayra-timeline-desc">Same lane, same box, same rail.</p>
</div>
</li>
</ol>

That is the part worth knowing: the lane is re-pointed by :has() on the root, never per entry. An icon anywhere in the list widens every node in it; an image or an Avatar widens it further. A bare dot beside an avatar would otherwise sit off the line, because the rail crosses the middle of one shared column.

An icon node paints on a well tinted from its role, the way a List media well does; an image node takes the page colour and nothing else. Give an Avatar --wayra-avatar-size: 100% and it fills the node.

One axis, so the four are exclusive. The DOM never changes between them — the stamp is a direct child of the entry in every layout, because an entry cannot know what the list asked for. Only the grid columns move.

  1. Order confirmed

    Card ending 4417 accepted.

  2. Packed

    Boxed at the Ñuñoa depot.

  3. Out for delivery

    Three stops ahead of yours.

opposite gives the stamp a column of its own before the rail, end-aligned against it.

Code
<Timeline layout="opposite" aria-label="Delivery status">…</Timeline>
<ol class="wayra-timeline" aria-label="Opposite layout" data-size="md" data-layout="opposite" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T09:41">09:41</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Order confirmed</span>
<p class="wayra-timeline-desc">Card ending 4417 accepted.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T11:20">11:20</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Packed</span>
<p class="wayra-timeline-desc">Boxed at the Ñuñoa depot.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="info" data-state="current">
<time class="wayra-timeline-time" datetime="2026-08-21T14:05">14:05</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Out for delivery</span>
<p class="wayra-timeline-desc">Three stops ahead of yours.</p>
</div>
</li>
</ol>
  1. Order confirmed

    Card ending 4417 accepted.

  2. Packed

    Boxed at the Ñuñoa depot.

  3. Afternoon
  4. Out for delivery

    Three stops ahead of yours.

  5. Estimated arrival

    Not yet left the last stop.

alternate zigzags around a centred rail — and the heading dropped in the middle does not flip the two entries after it.

Code
<Timeline layout="alternate" aria-label="Delivery status">
<TimelineItem … />
<TimelineItem … />
<TimelineGroup>Afternoon</TimelineGroup>
<TimelineItem … />
</Timeline>
<ol class="wayra-timeline" aria-label="Alternate layout" data-size="md" data-layout="alternate" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T09:41">09:41</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Order confirmed</span>
<p class="wayra-timeline-desc">Card ending 4417 accepted.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T11:20">11:20</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Packed</span>
<p class="wayra-timeline-desc">Boxed at the Ñuñoa depot.</p>
</div>
</li>
<li class="wayra-timeline-group">Afternoon</li>
<li class="wayra-timeline-item" data-color="info" data-state="current">
<time class="wayra-timeline-time" datetime="2026-08-21T14:05">14:05</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Out for delivery</span>
<p class="wayra-timeline-desc">Three stops ahead of yours.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="primary" data-state="upcoming">
<time class="wayra-timeline-time" datetime="2026-08-21T18:00">18:00</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Estimated arrival</span>
<p class="wayra-timeline-desc">Not yet left the last stop.</p>
</div>
</li>
</ol>

That last clause is the whole reason the parity selector is :nth-child(odd of .wayra-timeline-item) rather than :nth-child(odd): of S counts entries and skips the headings and markers sharing the list. The same mechanism trims the rail stubs — the first entry paints no half above it, the last none below — so a leading or trailing heading leaves nothing dangling past the outermost node. of S sits exactly at the browser floor.

  1. Q1
    Tokens and themes

    Seven palettes, both halves declared.

  2. Q2
    Form controls

    Six controls wired to react-hook-form.

  3. Q3
    Data display

    List, Stat, Table and this timeline.

    See the Table page

  4. Q4
    Overlays

    Modal, Drawer and Tooltip on the top layer.

horizontal lays the rail across the inline axis and turns the list into a scrolling row. snap settles it on the entry nearest the centre.

Code
<Timeline layout="horizontal" snap aria-label="Roadmap">…</Timeline>
<ol class="wayra-timeline" aria-label="Roadmap" data-size="md" data-layout="horizontal" data-rail="gradient" data-snap="true" style="inline-size: 100%; margin-inline: auto">
<li class="wayra-timeline-item" data-color="success" data-state="done">
<span class="wayra-timeline-time">Q1</span>
<span class="wayra-timeline-node" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M3.5 8.5 6.5 11.5 12.5 5"/></svg></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Tokens and themes</span>
<p class="wayra-timeline-desc">Seven palettes, both halves declared.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="success" data-state="done">
<span class="wayra-timeline-time">Q2</span>
<span class="wayra-timeline-node" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M3.5 8.5 6.5 11.5 12.5 5"/></svg></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Form controls</span>
<p class="wayra-timeline-desc">Six controls wired to react-hook-form.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="info" data-state="current">
<span class="wayra-timeline-time">Q3</span>
<span class="wayra-timeline-node" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 4.5h8v7h-8zM9.5 7h3l2 2.5v2h-5z"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="11.5" cy="12" r="1.2"/></svg></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Data display</span>
<p class="wayra-timeline-desc">List, Stat, Table and this timeline.</p>
<p class="wayra-timeline-desc"><a href="/components/table/">See the Table page</a></p>
</div>
</li>
<li class="wayra-timeline-item" data-color="primary" data-state="upcoming">
<span class="wayra-timeline-time">Q4</span>
<span class="wayra-timeline-node" aria-hidden="true"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 4.5h8v7h-8zM9.5 7h3l2 2.5v2h-5z"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="11.5" cy="12" r="1.2"/></svg></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Overlays</span>
<p class="wayra-timeline-desc">Modal, Drawer and Tooltip on the top layer.</p>
</div>
</li>
</ol>

segments is the default: every entry paints its own two halves, so anything that paints neither breaks the line. The other three draw one rail on the root instead, masked to fade out at both ends, and the entries stop painting halves entirely.

  1. Order confirmed

    Card ending 4417 accepted.

  2. Packed

    Boxed at the Ñuñoa depot.

  3. Out for delivery

    Three stops ahead of yours.

gradient runs the rail from the primary role to the secondary one. line is the same rail in the neutral hairline.

Code
<Timeline rail="gradient" aria-label="Delivery status">…</Timeline>
<ol class="wayra-timeline" aria-label="Gradient rail" data-size="md" data-rail="gradient" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T09:41">09:41</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Order confirmed</span>
<p class="wayra-timeline-desc">Card ending 4417 accepted.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T11:20">11:20</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Packed</span>
<p class="wayra-timeline-desc">Boxed at the Ñuñoa depot.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="info" data-state="current">
<time class="wayra-timeline-time" datetime="2026-08-21T14:05">14:05</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Out for delivery</span>
<p class="wayra-timeline-desc">Three stops ahead of yours.</p>
</div>
</li>
</ol>

The last three are vertical-only, and the stylesheet says so rather than pretending: a horizontal list keeps its segments whatever rail asks for, which is why the roadmap above still has a coloured line between its done entries.

The types say it first. TimelineProps is a union on layout, so a pairing the stylesheet would ignore does not compile: layout="horizontal" takes rail="segments" alone and no reveal, and snap is for horizontal only. Hand-written markup that pairs them anyway still renders, drawn as above.

  1. Today
  2. build finished in 42 s
  3. deploy published dist/ to the production bucket
  4. Now
  5. scheduled cache purge

log is the compact row: a tiny node, a monospaced stamp beside the message, and a title that truncates.

Code
<Timeline variant="log" aria-label="Deploy log">
<TimelineGroup>Today</TimelineGroup>
<TimelineItem color="success" time="09:41:02" dateTime="…" title={<><strong>build</strong> finished in 42 s</>} />
<TimelineNow label="Now" />
<TimelineItem state="upcoming" time="09:45:00" title="scheduled cache purge" />
</Timeline>
<ol class="wayra-timeline" aria-label="Deploy log" data-variant="log" data-size="md" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-group">Today</li>
<li class="wayra-timeline-item" data-color="success">
<time class="wayra-timeline-time" datetime="2026-08-21T09:41:02">09:41:02</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title"><strong>build</strong> finished in 42 s</span>
</div>
</li>
<li class="wayra-timeline-item" data-color="info">
<time class="wayra-timeline-time" datetime="2026-08-21T09:43:18">09:43:18</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title"><strong>deploy</strong> published dist/ to the production bucket</span>
</div>
</li>
<li class="wayra-timeline-now">
<span class="wayra-timeline-now-dot" aria-hidden="true"></span>
<span class="wayra-timeline-now-rule" role="separator" aria-label="Now">Now</span>
</li>
<li class="wayra-timeline-item" data-color="primary" data-state="upcoming">
<time class="wayra-timeline-time" datetime="2026-08-21T09:45:00">09:45:00</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">scheduled cache purge</span>
</div>
</li>
</ol>

TimelineGroup is presentational — it names a group visually and adds nothing to the accessibility tree. A group that must be announced as one is a nested <ol> with its own aria-label.

TimelineNow is not. It is a real role="separator", and where that role sits is the interesting part:

<li class="wayra-timeline-now">
<span class="wayra-timeline-now-dot" aria-hidden="true"></span>
<span class="wayra-timeline-now-rule" role="separator" aria-label="Now">Now</span>
</li>

The role is on the rule inside the row, never on the <li>. A list may only own list items, so an <ol> whose direct child carries another role is an axe only-listitems violation — the marker would break the very list it is marking. And a separator takes its name from the author rather than from its content, so a string label becomes aria-label while a ReactNode one renders visibly and leaves the separator unnamed. Pass a string, or name the rule yourself.

  1. Timeline

    Four layouts, four rails and the now marker.

  2. Table and List

    The row grid this timeline reuses.

The node becomes a version chip: fixed width, so the rail still runs through the middle of its column.

Code
<Timeline variant="changelog" aria-label="Release history">
<TimelineItem color="success" node="v2.4.0" time="21 Aug 2026" dateTime="2026-08-21" title="Timeline">
<Badge color="success" size="sm">new</Badge>
</TimelineItem>
</Timeline>
<ol class="wayra-timeline" aria-label="Release history" data-variant="changelog" data-size="md" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-item" data-color="success">
<time class="wayra-timeline-time" datetime="2026-08-21">21 Aug 2026</time>
<span class="wayra-timeline-node" aria-hidden="true">v2.4.0</span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Timeline</span>
<p class="wayra-timeline-desc">Four layouts, four rails and the now marker.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="info">
<time class="wayra-timeline-time" datetime="2026-08-14">14 Aug 2026</time>
<span class="wayra-timeline-node" aria-hidden="true">v2.3.1</span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Table and List</span>
<p class="wayra-timeline-desc">The row grid this timeline reuses.</p>
</div>
</li>
</ol>

Tags, cards and images inside an entry are all children — the component ships no badge slot, no media slot and no action slot. Anything you put there renders under the description, inside the content column.

The reveal, and what a browser without it gets

Section titled “The reveal, and what a browser without it gets”

rail="draw" and reveal="scroll" are scroll-driven CSS: the rail fills as the list crosses the scrollport and each entry lifts into place, with no observer and no JavaScript. Both live inside one of the library’s five support queries, and each reads its whole animation from a variable on the root:

.wayra-timeline {
--_wayra-timeline-draw-animation: wayra-timeline-draw linear both;
--_wayra-timeline-reveal-animation: wayra-timeline-reveal linear both;
}
@supports (animation-timeline: view()) {
.wayra-timeline:not([data-layout="horizontal"])[data-rail="draw"]::after {
animation: var(--_wayra-timeline-draw-animation);
animation-timeline: view();
}
.wayra-timeline[data-reveal="scroll"] .wayra-timeline-item {
animation: var(--_wayra-timeline-reveal-animation);
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
@keyframes wayra-timeline-draw {
from { scale: 1 0; }
to { scale: 1 1; }
}
}
  1. Order confirmed

    Card ending 4417 accepted.

  2. Packed

    Boxed at the Ñuñoa depot.

  3. Out for delivery

    Three stops ahead of yours.

  4. Estimated arrival

    Not yet left the last stop.

The whole history

Scroll inside the box. The scrollport is the consumer's, and so is the link that keeps it keyboard-reachable.

Code
<Timeline rail="draw" reveal="scroll" aria-label="Delivery status">…</Timeline>
<div style="block-size: 15rem; overflow-y: auto">
<ol class="wayra-timeline" aria-label="Scroll-driven timeline" data-size="md" data-rail="draw" data-reveal="scroll" style="inline-size: min(100%, 30rem); margin-inline: auto">
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T09:41">09:41</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Order confirmed</span>
<p class="wayra-timeline-desc">Card ending 4417 accepted.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T11:20">11:20</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Packed</span>
<p class="wayra-timeline-desc">Boxed at the Ñuñoa depot.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="info" data-state="current">
<time class="wayra-timeline-time" datetime="2026-08-21T14:05">14:05</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Out for delivery</span>
<p class="wayra-timeline-desc">Three stops ahead of yours.</p>
</div>
</li>
<li class="wayra-timeline-item" data-color="primary" data-state="upcoming">
<time class="wayra-timeline-time" datetime="2026-08-21T18:00">18:00</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Estimated arrival</span>
<p class="wayra-timeline-desc">Not yet left the last stop.</p>
</div>
</li>
</ol>
<p style="text-align: center"><a href="/components/list/">The whole history</a></p>
</div>

The fallback is complete because everything the block adds is additive. Below the tier, draw is the static masked hairline the rule above the block already draws, and reveal does nothing at all — every entry is simply visible, in place, at full opacity. Nothing is left hidden waiting for an animation that will never run, which is the failure mode this shape exists to avoid.

Reduced motion is one block, outside the support query, so it covers both tiers. Under prefers-reduced-motion: reduce it sets both variables to none. The rules themselves are never restated, so the rescue cannot lose to them on specificity. The empty rail is the first keyframe rather than a declaration on the rule, so with the animation gone the rail is drawn full-height: a reader who asked for less motion still gets a finished rail rather than an empty one. The same block sets the hover transition to 0s and brings the current node’s pulse to rest as the ring it spends most of its cycle as.

reveal is vertical-only for a mechanical reason, not a stylistic one: view() resolves on the block axis, and a horizontal scrollport has no block scroll range for it to read.

The class plus the data-* attributes are public API — every preview on this page is hand-authored HTML with no React in the page:

<ol class="wayra-timeline" aria-label="Delivery status" data-size="md">
<li class="wayra-timeline-group">Today</li>
<li class="wayra-timeline-item" data-color="success" data-state="done">
<time class="wayra-timeline-time" datetime="2026-08-21T09:41">09:41</time>
<span class="wayra-timeline-node" aria-hidden="true"></span>
<div class="wayra-timeline-content">
<span class="wayra-timeline-title">Order confirmed</span>
<p class="wayra-timeline-desc">Card ending 4417 accepted.</p>
</div>
</li>
</ol>

variant, layout and rail always write their resolved value, default, start and segments included, the way every defaulted attribute in the library does. The stylesheet reads a missing attribute as the default, so hand-written markup may leave all three off, as the one above does.

Property Default What it does
--wayra-timeline-node calc(var(--wayra-size-selector) * 3.2) at md The node’s diameter, and where the rail crosses.
--wayra-timeline-gap calc(var(--wayra-size-field) * 3.5) Column gap, and the space under an entry.
--wayra-timeline-pad calc(var(--wayra-size-field) * 1.5) Block padding on an entry.
--wayra-timeline-line-w 2px The rail’s thickness, and the ring on a hollow node.
--wayra-timeline-time-col 4.5rem The stamp’s own column under opposite.

TimelineProps is a union on layout (see Rails) whose two halves both extend ComponentProps<"ol">, so id, style, ref, className and aria-label land on the <ol>.

Prop Type Default Description
variant "default" | "changelog" | "log" "default" changelog turns the node into a version chip; log is a compact monospaced row.
layout "start" | "opposite" | "alternate" | "horizontal" "start" Where the content sits against the rail.
size "sm" | "md" | "lg" "md" The node’s diameter and the entry’s type step.
rail "segments" | "line" | "gradient" | "draw" "segments" Per-entry halves, or one rail on the root. The last three are vertical-only.
reveal "scroll" — Fades and lifts each entry as it enters the scrollport. Vertical only.
snap boolean false Snaps the scrolling row to the centred entry. horizontal only.
children ReactNode — TimelineItem, TimelineGroup, TimelineNow. Required.
unstyled boolean false Drops the wayra-timeline class, keeps every attribute.

TimelineItemProps extends ComponentProps<"li"> minus color and title — both names are taken by props below.

Prop Type Default Description
color WayraColor "primary" Node, done rail and current title. Emitted as data-color.
state "done" | "current" | "upcoming" — A look. Emitted as data-state when given.
interactive boolean false Hover wash on the content block. Wires no handlers and no keyboard.
headingLevel 2 | 3 | 4 | 5 | 6 — Renders the title as a real heading instead of a <span>.
node ReactNode — What goes inside the node. Empty draws the plain dot. aria-hidden.
time ReactNode — The visible stamp.
dateTime string — Machine stamp; with it the slot is a real <time datetime>.
title ReactNode — The entry’s main line.
description ReactNode — A paragraph under the title, muted.
year ReactNode — A large year behind the content as a watermark. Decoration: aria-hidden and inert.
children ReactNode — Anything else, under the description, inside the content column.
unstyled boolean false Drops wayra-timeline-item and every part class on this entry.

Both extend ComponentProps<"li"> and both render an <li>.

Prop Type Description
children ReactNode TimelineGroup only: the heading text. Required.
label ReactNode TimelineNow only: the marker’s visible text, and the separator’s accessible name when it is a string. Required.
unstyled boolean Drops the class on that instance.

unstyled is per instance: an entry inside an unstyled list keeps its own class unless it opts out too.

  • It is a real ordered list. An <ol> whose children are all <li> — entries, headings and the now marker alike. Name it with aria-label, or point aria-labelledby at a heading above it.
  • The separator is on an inner span. Putting role="separator" on the <li> would make the list own a non-list-item, which is the axe rule only-listitems. The row stays a list item; the rule inside it carries the role and the name.
  • The node says nothing. It is aria-hidden, along with the year watermark. Anything that only lives in a node or a watermark is not announced.
  • state is not an announcement. “Done”, “current” and “upcoming” are colours and strokes. Put the word in the entry’s own text if it matters.
  • Headings are opt-in. A title is a <span> unless you pass headingLevel — an entry is a row far more often than it is a document section, and inventing an <h3> per entry would wreck the page’s heading outline.
  • Contrast. In the default theme, titles are base-content on base-100 (16.9:1 light, 15.1:1 dark); descriptions, stamps, group headings and an upcoming entry’s text take the shared muted colour (5.64:1 / 6.15:1). A current title is the role solid and the now marker is always the primary solid — both on the page, where the colour contract holds 4.5:1. The year watermark is 7% ink, measured at 1.15:1 / 1.20:1 (1.13–1.20:1 across the seven themes): WCAG 1.4.3 exempts it as decoration. axe checks aria-hidden text for contrast, so the watermark is also inert, which axe skips. Write the markup by hand the same way: <span class="wayra-timeline-year" aria-hidden="true" inert>.
  • Forced colors. Rails and node borders take CanvasText (the gradient rail falls back to a solid one), an upcoming node goes back to Canvas with a CanvasText ring, and the now marker’s dot, text and rule all take CanvasText.
  • Right to left. Every measurement is logical, so the rail and the zigzag mirror on their own. The now marker’s fade is a physical gradient, so it turns under a dir="rtl" on the marker or any ancestor, which the browser floor understands; :dir() (Chrome 120) only puts an LTR island back.
  • Reduced motion is one block that covers both tiers: the two scroll-driven animations are dropped and the drawn rail rests full-height, the hover transition goes to 0s, and the current pulse rests as a ring.

Do

  1. 09:41
    Packed
  2. Now
  3. 18:00
    Arrival

role="separator" on the rule inside the

  • . The list still owns nothing but list items.

  • Don't

    1. 09:41
      Packed
    2. 18:00
      Arrival

    The role hoisted onto the

  • . The
      now owns a child that is not a list item — an axe only-listitems violation, and the item count is wrong.

  • Do

    1. 11:20
      Packed — done

      The word is in the text, not only in the colour.

    2. 18:00
      Arrival — pending

      Announced as written.

    The state is in the entry's own words. A screen reader reads “Packed — done”.

    Don't

    1. 11:20
      Packed
    2. 18:00
      Arrival

    Two entries that read identically to a screen reader. The filled rail and the hollow node are paint; data-state is in no accessibility tree.