A release history, an order tracker, a project plan — the vertical strip of dated milestones on a line. I always half-assumed it needed a component library, or at least a pile of divs and a lot of positioning. It doesn't. A timeline is an ordered list because order is the meaning, the connecting rail is one pseudo-element, and the three states each node can be in are derived from a single index rather than stored. Once I built it from scratch the whole recipe fit in my head. Here's the version I keep now.
The skeleton is ol > li, because order is the meaning
Kickoff comes before beta comes before launch — shuffle the items and the timeline tells a lie. That's the definition of an ordered list, so it's a real <ol>, labelled so assistive tech announces "Project timeline, list of 5 items."
<ol class="tl vertical" aria-label="Project timeline">
<li class="tl-item done">…</li>
<li class="tl-item current">…</li>
<li class="tl-item upcoming">…</li>
</ol>
Each <li> holds exactly two children: a decorative dot (<span class="tl-node" aria-hidden="true">) and the content — a <time datetime="2024-04-02"> for the machine-readable date, a title, and a description.
The rail is one ::before, not a hundred borders
The line threading every node is a single pseudo-element on the <ol> itself, absolutely positioned and inset top-and-bottom so it spans the whole list no matter how many items you add.
.tl { position:relative; }
.tl::before { content:""; position:absolute; background:#e2e8f0; z-index:0; }
.tl.vertical::before { left:11px; top:9px; bottom:9px; width:2px; }
The nodes sit at z-index:2, on top of the rail. No per-item hacks, no fixed height.
States are derived from one index — never stored
This is the single most important idea in the component. I don't store "done" on each event. I store one integer, current, and compute each item's state from where it sits relative to it.
function stateOf(i){
return i < current ? "done" : i === current ? "current" : "upcoming";
}
Everything ahead is upcoming, exactly one item is current (it gets aria-current="step"), everything behind is done. Advancing the timeline is one line — current++ — and the whole thing re-renders. The done checkmark is even drawn in pure CSS, no icon font or SVG: a rotated border on .tl-node::after.
Alternating sides are free from :nth-child
The zig-zag layout everyone reaches for a framework for is just :nth-child(odd/even) plus width:50%. The data and JS never change — one class on the <ol> toggles it.
.tl.alternating .tl-item:nth-child(odd) { left:0; text-align:right; }
.tl.alternating .tl-item:nth-child(even) { left:50%; text-align:left; }
The rail moves to the centre, odd items hug it from the left, even from the right, and the node offsets flip so the dot always straddles the line.
Render from data, collapse on mobile
The live demo keeps two things — an events array and the current index — and every interaction is "mutate state, then render()." Add an event, remove one, advance the step, click any node to jump: all of it is a pure function of those two values, and render() rebuilds the <ol> from scratch each time. Compact mode is the same trick applied to spacing — one class flips a CSS variable (--gap) and hides the descriptions, so the rhythm tightens without touching the markup. Below 560px the alternating layout collapses back to a single left rail with a few !important overrides, so the same markup reads cleanly on a phone without a horizontal scrollbar. And because the dots and the rail are aria-hidden decoration, a screen reader hears "list of 5 items… Beta release, current step" without ever reading the visual scaffolding — so respect prefers-reduced-motion and kill the current-node pulse too.
That's the whole thing: an <ol> because order is the hierarchy, a <time> per event, one pseudo-element for the rail, three states derived from a single index, and a zig-zag that costs two :nth-child rules. No library — just the honest element and a bit of CSS.
Add events, advance the step, and flip between vertical and alternating:
https://dev48v.infy.uk/design/day48-timeline.html
Top comments (0)