Timelines are one of those UI patterns that look deceptively simple but require just enough CSS knowledge to trip you up. A connecting vertical line, numbers that stay perfectly centered on that line, content that does not collapse awkwardly on mobile — each piece seems straightforward until you combine them. In Tailwind CSS the good news is you can handle the entire layout in markup without a single line of custom CSS.
This guide walks through building a numbered vertical timeline step by step. By the end you will have a reusable, accessible component that works in plain HTML, Astro, or any framework that passes through Tailwind classes.
What we are building
The finished component is a vertical list of steps. Each step has:
- A circular number badge on the left
- A vertical line connecting every badge except the last one
- A title and a description to the right of the badge
The key layout decision is using CSS Grid inside each step item. Grid makes it trivial to span the connecting line the full height of the row without JavaScript or magic numbers.
The HTML structure
Start with a plain ordered list. Using <ol> gives you semantics for free — screen readers will announce the step number, and search engines understand the sequence.
<ol class="relative">
<li>...</li>
<li>...</li>
<li>...</li>
</ol>
Each <li> uses a two-column grid: a narrow left column for the badge and line, and a flexible right column for the content.
<li class="grid grid-cols-[3rem_1fr] gap-x-4">
<!-- Left column: badge + line -->
<div class="flex flex-col items-center">
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full bg-slate-900 text-sm font-bold text-white">
1
</div>
<!-- Line that fills the remaining height -->
<div class="mt-2 w-px grow bg-slate-200"></div>
</div>
<!-- Right column: content -->
<div class="pb-10">
<h3 class="text-base font-semibold text-slate-900">Install dependencies</h3>
<p class="mt-1 text-sm text-slate-600">
Run <code>npm install</code> in your project directory to pull in Tailwind and its peer packages.
</p>
</div>
</li>
The grow class on the line element is the key insight. Inside the flex column, flex-grow: 1 tells the line to consume every pixel between the bottom of the badge and the bottom of the list item. The right column drives the height of the row through its pb-10 padding, and the line stretches to match automatically.
Suppressing the line on the last step
The last item should not have a connecting line reaching down into nothing. Tailwind's last: variant handles this cleanly:
<div class="mt-2 w-px grow bg-slate-200 last:hidden"></div>
Wait — last: here refers to this element being the last child, but this <div> is always the last child inside the flex column. You need the variant to apply when the parent <li> is the last child of the <ol>.
Use Tailwind's group variant for this. Mark the <li> as a group and then use group-last:hidden on the line:
<li class="group grid grid-cols-[3rem_1fr] gap-x-4">
<div class="flex flex-col items-center">
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full bg-slate-900 text-sm font-bold text-white">
1
</div>
<div class="mt-2 w-px grow bg-slate-200 group-last:hidden"></div>
</div>
<div class="pb-10">
<h3 class="text-base font-semibold text-slate-900">Step title</h3>
<p class="mt-1 text-sm text-slate-600">Step description goes here.</p>
</div>
</li>
Now group-last:hidden applies display: none to the line only when the <li> that has the group class is the last child. On the last step, the right column padding still exists, but you can remove pb-10 from the last item too using group-last:pb-0 on the content div.
The complete component
Here is a full four-step example you can copy and paste directly:
<ol>
<!-- Step 1 -->
<li class="group grid grid-cols-[3rem_1fr] gap-x-4">
<div class="flex flex-col items-center">
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full bg-slate-900 text-sm font-bold text-white">
1
</div>
<div class="mt-2 w-px grow bg-slate-200 group-last:hidden"></div>
</div>
<div class="pb-10 group-last:pb-0">
<h3 class="text-base font-semibold text-slate-900">Install Tailwind</h3>
<p class="mt-1 text-sm text-slate-600">
Add Tailwind to your project with <code>npm install tailwindcss</code>
and import it in your main CSS file.
</p>
</div>
</li>
<!-- Step 2 -->
<li class="group grid grid-cols-[3rem_1fr] gap-x-4">
<div class="flex flex-col items-center">
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full bg-slate-900 text-sm font-bold text-white">
2
</div>
<div class="mt-2 w-px grow bg-slate-200 group-last:hidden"></div>
</div>
<div class="pb-10 group-last:pb-0">
<h3 class="text-base font-semibold text-slate-900">Configure your theme</h3>
<p class="mt-1 text-sm text-slate-600">
Open your CSS file and use <code>@theme</code> to define custom colors,
fonts, and spacing tokens that generate utility classes automatically.
</p>
</div>
</li>
<!-- Step 3 -->
<li class="group grid grid-cols-[3rem_1fr] gap-x-4">
<div class="flex flex-col items-center">
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full bg-slate-900 text-sm font-bold text-white">
3
</div>
<div class="mt-2 w-px grow bg-slate-200 group-last:hidden"></div>
</div>
<div class="pb-10 group-last:pb-0">
<h3 class="text-base font-semibold text-slate-900">Build your layout</h3>
<p class="mt-1 text-sm text-slate-600">
Use utility classes in your HTML. Tailwind scans your files and only
ships the classes you actually use.
</p>
</div>
</li>
<!-- Step 4 (last — no line) -->
<li class="group grid grid-cols-[3rem_1fr] gap-x-4">
<div class="flex flex-col items-center">
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full bg-slate-900 text-sm font-bold text-white">
4
</div>
<div class="mt-2 w-px grow bg-slate-200 group-last:hidden"></div>
</div>
<div class="pb-10 group-last:pb-0">
<h3 class="text-base font-semibold text-slate-900">Deploy</h3>
<p class="mt-1 text-sm text-slate-600">
Push to production. The output is a small static CSS file — no runtime,
no JavaScript, nothing extra to load.
</p>
</div>
</li>
</ol>
How the layout actually works
It is worth being precise about each layout decision so you can adapt it confidently.
The two-column grid
grid-cols-[3rem_1fr] uses an arbitrary value to set the first column to exactly 3rem and the second column to fill the rest. Three rems is enough to center a 2.5rem (w-10 h-10) badge with a little breathing room. If you use a larger badge — say w-12 h-12 — bump the first column to 4rem accordingly.
Why flex instead of grid inside the left column
The left column itself uses flex flex-col items-center. Grid would also work, but flex with items-center horizontally centers both the badge and the line without needing to set explicit widths. The shrink-0 on the badge prevents it from being compressed if the text content is minimal.
The connecting line
w-px sets the line to exactly one pixel wide. grow (short for flex-grow: 1) makes it expand to fill the remaining vertical space in the flex column. The mt-2 adds a small gap between the bottom of the badge and the top of the line, so the line does not start directly against the circle border.
Bottom padding drives spacing
The pb-10 on the content column is what determines the gap between steps. Increasing it to pb-16 adds more vertical space between entries. Because the line element uses grow, it automatically extends to match whatever height the content creates — you never need to hardcode a height.
Customising the badge style
Swap the dark badge for a brand color using your @theme tokens in Tailwind v4:
/* In your CSS entry file */
@import "tailwindcss";
@theme {
--color-brand-600: oklch(50% 0.22 265);
--color-brand-50: oklch(97% 0.03 265);
}
Then use those tokens in the badge class: bg-brand-600 text-white. Tailwind generates bg-brand-600 automatically from the --color-brand-600 theme variable — no plugin, no config file.
For a lighter, outlined variant, swap to a ring instead of a filled background:
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full ring-2 ring-brand-600 text-sm font-bold text-brand-600">
1
</div>
And for a "completed step" state — useful in multi-step forms — you can swap the number for a checkmark using a conditional class. In Astro or any template language, derive the class from your step data rather than duplicating markup.
Accessibility considerations
A few small additions make this timeline useful to everyone:
- Use
<ol>not<ul>. The ordered list communicates sequence to screen readers and assistive technology. A<ul>technically works but loses the semantic meaning. - The visual number inside the badge is sufficient — do not add a redundant
aria-label="Step 1"unless you have stripped the number from the visible markup entirely. - The decorative connecting line has no meaning for screen reader users, which is fine — it is a layout element, not content.
- Keep the heading level appropriate to the surrounding document. If this timeline sits inside an
<article>that starts with an<h2>, use<h3>for each step title.
Rendering it from data in Astro
In a real project you will almost certainly drive a timeline from an array rather than hand-writing the HTML for each step. In Astro the pattern is clean:
---
const steps = [
{ title: "Install Tailwind", body: "Run npm install tailwindcss." },
{ title: "Configure your theme", body: "Use @theme in your CSS entry file." },
{ title: "Build your layout", body: "Tailwind scans and ships only what you use." },
{ title: "Deploy", body: "Push to production." },
];
---
<ol>
{steps.map((step, i) => (
<li class="group grid grid-cols-[3rem_1fr] gap-x-4">
<div class="flex flex-col items-center">
<div class="flex h-10 w-10 shrink-0 items-center justify-center
rounded-full bg-slate-900 text-sm font-bold text-white">
{i + 1}
</div>
<div class="mt-2 w-px grow bg-slate-200 group-last:hidden"></div>
</div>
<div class="pb-10 group-last:pb-0">
<h3 class="text-base font-semibold text-slate-900">{step.title}</h3>
<p class="mt-1 text-sm text-slate-600">{step.body}</p>
</div>
</li>
))}
</ol>
Change the steps array and the component re-renders correctly — including the group-last:hidden logic on the final item — with no manual adjustments.
Wrapping up
The numbered timeline pattern breaks down into three ideas: a two-column grid to separate the badge column from the content column, a flex column inside the badge side so a grow element can stretch to fill available height, and group-last: to suppress the trailing line. Once you see the structure, every visual variation — colors, sizes, icon badges, status states — is just swapping classes on the same skeleton. No custom CSS, no JavaScript, and nothing to maintain beyond the markup itself.