Building a Vertical Timeline Component with Alpine.js
AI generated
x-data
Alpine
Alpine.js · Timeline · Case Study
Building a Vertical Timeline with Alpine.js
CSS Grid layout and scroll-reveal without a library

A vertical timeline component displays events chronologically along a vertical line and reveals steps as the user scrolls. With CSS Grid for the layout and Alpine.js x-intersect for scroll-reveal animations, a flexible timeline component emerges without loading a dedicated timeline library.

19 min read x-data · x-intersect · CSS Grid Alpine.js 3.x

1. Why a vertical timeline component makes sense

A vertical timeline component is well suited for company histories, project progressions, product roadmaps and changelogs. Instead of listing events in a plain list, the vertical timeline arranges them along a continuous line, making the chronological order instantly readable. Many projects reach for a ready made timeline library for this, even though the underlying layout with CSS Grid and a bit of Alpine.js for interactivity can be built by hand in a short amount of time.

The advantage of a self built vertical timeline becomes especially clear for custom requirements: custom icons per step, an alternating layout that renders alternately left and right of the line on wide screens, or a scroll-reveal animation that only fades in steps once they reach the viewport. Ready made libraries often cover only a fraction of these requirements while also bringing their own CSS, which can clash with an existing Tailwind setup.

This article builds a complete vertical timeline component: CSS Grid for the basic structure, an Alpine.js array for the event data, x-intersect for scroll-reveal animations, and additional logic to visually highlight the currently visible step.

2. Basic structure: CSS Grid for the line and steps

The basic skeleton of a vertical timeline can be elegantly implemented with CSS Grid: one column for the continuous vertical line with the dots, a second column for the content of each step. The line itself is a single div with a fixed width and a background that spans the full height of all steps via grid-row: 1 / -1, while each step positions its own dot centered on the line via position: relative and a small circle.

This grid based structure for a vertical timeline avoids the technique common in older tutorials that relies on absolutely positioned pseudo elements and manually calculated pixel spacing. With grid rows that automatically adapt to the content of each step, the line always stays exactly as long as the sum of the steps, regardless of how much text a single step contains.


<div x-data="verticalTimeline()" class="relative grid grid-cols-[2rem_1fr] gap-x-6">
  <!-- Continuous line, spans the full grid height -->
  <div class="col-start-1 row-span-full w-0.5 bg-teal-200 justify-self-center" style="grid-row: 1 / -1;"></div>

  <template x-for="(item, index) in events" :key="item.id">
    <div class="contents">
      <!-- Dot marker, centered on the line -->
      <div class="col-start-1 row-start-auto w-8 h-8 rounded-full bg-white border-2 border-teal-500 flex items-center justify-center self-start z-10">
        <span class="w-2.5 h-2.5 rounded-full bg-teal-500"></span>
      </div>
      <!-- Event content -->
      <div class="col-start-2 pb-10">
        <p class="text-sm font-semibold text-teal-700" x-text="item.date"></p>
        <p class="text-lg font-bold text-slate-900" x-text="item.title"></p>
        <p class="text-sm text-slate-600" x-text="item.description"></p>
      </div>
    </div>
  </template>
</div>

3. Data structure: modeling events as an array

The data structure of a vertical timeline is a simple array of event objects with date, title, description and optionally an icon name. A stable id field per event matters, since x-for needs this id for the :key binding, in order to reuse the correct DOM elements during re rendering instead of recreating them entirely.

In addition to plain display data, the vertical timeline needs an isVisible field for the scroll-reveal feature, initially set to false for all events and only set once the respective step scrolls into view. This separation between static display data and dynamic visibility state keeps the code readable and makes the timeline easy to extend, for example with category filtering.


function verticalTimeline() {
  return {
    events: [
      { id: 1, date: '2023', title: 'Project kickoff', description: 'Initial concept and requirements analysis.', isVisible: false },
      { id: 2, date: '2024', title: 'Beta launch', description: 'First version released to selected customers.', isVisible: false },
      { id: 3, date: '2025', title: 'Full rollout', description: 'Production launch for all users.', isVisible: false },
      { id: 4, date: '2026', title: 'Expansion', description: 'New modules and international availability.', isVisible: false },
    ],

    markVisible(eventId) {
      const event = this.events.find((e) => e.id === eventId);
      if (event) event.isVisible = true;
    },
  };
}

4. Scroll-reveal with x-intersect

Alpine.js Intersect is the plugin that equips the vertical timeline with scroll-reveal animations, without loading an external library such as AOS or ScrollReveal. The x-intersect directive executes an expression exactly when the element enters the visible area of the viewport, technically based on the browser's native IntersectionObserver API.

For the vertical timeline, a simple x-intersect="markVisible(item.id)" per step is enough, combined with a :class binding that switches between a revealed and a hidden state depending on item.isVisible. The x-intersect.once modifier ensures that a step, once revealed, does not get hidden again on further scrolling, which is the desired behavior for most timeline use cases.


<template x-for="item in events" :key="item.id">
  <div class="contents">
    <div class="col-start-1 w-8 h-8 rounded-full bg-white border-2 border-teal-500 self-start z-10"></div>
    <div
      class="col-start-2 pb-10 transition-all duration-700"
      x-intersect.once="markVisible(item.id)"
      :class="item.isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6'"
    >
      <p class="text-sm font-semibold text-teal-700" x-text="item.date"></p>
      <p class="text-lg font-bold text-slate-900" x-text="item.title"></p>
      <p class="text-sm text-slate-600" x-text="item.description"></p>
    </div>
  </div>
</template>

A common mistake with a vertical timeline using x-intersect: the initial state before visibility must already be set through :class, otherwise the content briefly flashes visible before the transition animation kicks in. That is why the opacity-0 class should be the default value in the template, not applied dynamically through JavaScript afterward.

5. Highlighting the active step while scrolling

Beyond plain reveal animation, a vertical timeline benefits from visually highlighting the step currently in focus, similar to a progress bar. To achieve this, in addition to x-intersect.once for the reveal animation, a second, repeatable intersect handler without the once modifier is used, which updates an activeEventId variable in the top level state as soon as a step crosses the middle of the screen.

The threshold configuration is crucial here: x-intersect:threshold.50="setActive(item.id)" only fires once at least half the element is visible, which delivers a much more precise result for the vertical timeline than the default threshold of zero percent, where the first visible pixel already suffices.


function verticalTimeline() {
  return {
    events: [ /* ... */ ],
    activeEventId: null,

    setActive(eventId) {
      this.activeEventId = eventId;
    },
    isActive(eventId) {
      return this.activeEventId === eventId;
    },
  };
}

6. Alternating layout for desktop views

On wide screens a vertical timeline often looks more elegant when steps alternate between left and right of the center line, instead of all stacking on the same side. This variant needs a third grid column, where odd steps use the left area and even steps use the right area, while the line stays exactly in the middle column.

Alpine.js calculates the side assignment directly in the template using the index of the x-for loop: index % 2 === 0 determines whether a step of the vertical timeline is placed on the left or the right, combined with conditional Tailwind classes for text alignment and grid column position.


<div x-data="verticalTimeline()" class="relative grid grid-cols-[1fr_2rem_1fr] gap-x-6 max-lg:grid-cols-[2rem_1fr]">
  <div class="col-start-2 row-span-full w-0.5 bg-teal-200 justify-self-center max-lg:col-start-1" style="grid-row: 1 / -1;"></div>

  <template x-for="(item, index) in events" :key="item.id">
    <div class="contents">
      <div
        class="w-8 h-8 rounded-full bg-white border-2 border-teal-500 self-start z-10 justify-self-center max-lg:col-start-1"
        :class="index % 2 === 0 ? 'col-start-2' : 'col-start-2'"
      ></div>
      <div
        class="pb-10 max-lg:col-start-2"
        :class="index % 2 === 0 ? 'col-start-1 text-right' : 'col-start-3 text-left'"
      >
        <p class="text-sm font-semibold text-teal-700" x-text="item.date"></p>
        <p class="text-lg font-bold text-slate-900" x-text="item.title"></p>
      </div>
    </div>
  </template>
</div>

7. Responsive behavior: from two column to single column

The alternating layout of a vertical timeline only works on sufficiently wide screens, on mobile devices the split display would further limit the already scarce horizontal space. That is why the timeline automatically falls back to a single column layout below a defined breakpoint, in the example lg, where all steps sit on the same side of the line.

The switch between layouts happens purely through Tailwind breakpoint prefixes such as max-lg:col-start-1, with no additional JavaScript logic or window.matchMedia checks in Alpine.js at all. This CSS only solution is more robust than a JavaScript based breakpoint detection, since it reacts to resizing without delay and does not cause an additional reflow through JavaScript calculations.

8. Performance: IntersectionObserver instead of scroll listeners

Before the IntersectionObserver API, scroll-reveal effects for a vertical timeline were implemented almost exclusively through scroll event listeners, which recalculated the position of every timeline element via getBoundingClientRect() on every single scroll event. On a long timeline with many steps, these calculations added up to noticeable performance issues, especially on mobile devices with limited processing power.

x-intersect and the underlying IntersectionObserver API work fundamentally differently: the browser monitors element visibility itself, outside the main thread, and only notifies Alpine.js on actual visibility changes. For a vertical timeline with twenty or more steps, this difference is not just a theoretical optimization but noticeable in practice on weaker hardware.

9. Timeline approaches compared

Several technical implementations exist for a vertical timeline component, with different trade offs.

Approach Layout technique Scroll reveal Best fit
CSS Grid plus Alpine.js x-intersect Native, no JS layout IntersectionObserver Default case, any project size
Flexbox with absolute line Manual pixel calculation Depends on implementation Simple, short timelines
AOS or ScrollReveal library Own CSS system Scroll listener based Only if already in use elsewhere
SVG path based timeline SVG path, complex Depends on implementation Curved, non linear paths

For the vast majority of use cases, CSS Grid combined with Alpine.js x-intersect delivers the best ratio of maintainability, performance and bundle size for a vertical timeline. Only for strongly curved, non linear timeline paths does the extra effort of an SVG path based solution pay off.

Mironsoft

Alpine.js components for Hyvä, Magento and custom frontends

Need a custom timeline component or another Alpine.js solution?

We build custom Alpine.js components, from timelines to scroll-reveal sections and complex landing page elements, cleanly integrated into your existing Hyvä or Magento frontend.

Concept

Clarifying layout and interaction patterns for your timeline

Implementation

CSS Grid, x-intersect and responsive layout from a single source

Integration

Clean integration into existing Hyvä and Magento frontends

10. Summary

A vertical timeline component can be fully implemented with CSS Grid for the layout and Alpine.js for interactivity, without loading a dedicated timeline library. Grid columns handle the positioning of the line, dots and content, while a simple array of event objects forms the data structure.

Scroll-reveal animations come into play through x-intersect, which is based on the native IntersectionObserver API and therefore works considerably more efficiently than classic scroll event listeners. For desktop views, an alternating layout computed from the index of the x-for loop is worth the effort, while responsive breakpoints automatically demote the vertical timeline to a single column layout on mobile devices.

Vertical Timeline with Alpine.js — The Essentials at a Glance

Layout

CSS Grid with columns for the line and content, no manual pixel calculation needed.

Scroll-reveal

x-intersect.once gently reveals steps once they reach the viewport.

Active step

A threshold based intersect handler marks the step at the middle of the screen.

Responsive

Tailwind breakpoints switch between alternating and single column layout.

11. FAQ: Vertical Timeline with Alpine.js

1Why CSS Grid instead of Flexbox?
Grid spans the line across all steps automatically, no manual height calculation.
2Do I need extra plugins?
Only for scroll-reveal, the official Alpine Intersect plugin.
3How does x-intersect work?
Based on IntersectionObserver, runs expressions when entering the viewport.
4What does the once modifier do?
The handler fires only on first visibility, not on every entry and exit.
5How do I mark the active step?
With a repeatable intersect handler and threshold configuration.
6How do I build an alternating layout?
Via a third grid column and the index of the x-for loop.
7How does it become responsive?
Via Tailwind breakpoint prefixes, no JavaScript needed.
8Why more performant than scroll listeners?
The browser monitors visibility outside the main thread.
9Also possible horizontally?
Yes, with grid rows instead of columns, same basic principle.
10Build it or use AOS/ScrollReveal?
Usually build it, for better performance and no clashing CSS system.