EmpireUI
Get Pro
← Blog8 min read#timeline#react#tailwind

Timeline Component in React: Vertical, Horizontal, Alternating

Build vertical, horizontal, and alternating timeline components in React with Tailwind CSS. Real code, no libraries required — just clean JSX and flex/grid layouts.

Developer coding a React timeline component on a dark monitor screen

Why Timeline Components Are Harder Than They Look

Timelines feel deceptively simple — a line, some dots, a bit of text. Then you actually start building one and realize you've got three completely different layout modes (vertical, horizontal, alternating), responsive breakpoints where horizontal collapses to vertical, and connector lines that need to span dynamically-sized content. It gets messy fast.

Honestly, most developers reach for a library, drop in 200 KB of dependencies, and call it a day. That's fine until you need to tweak the connector color, swap in a custom icon, or animate entry — and suddenly you're fighting the library's internal CSS instead of writing your own.

This guide builds all three variants from scratch with React and Tailwind CSS 3.x. No external timeline library. You'll understand the exact px math behind the connector positioning, and you'll own the component fully.

Worth noting: the patterns here work equally well in Next.js 14+ App Router as they do in plain Vite + React setups. The components are purely presentational — no client/server boundary issues to worry about.

The Vertical Timeline: Bread and Butter

The vertical layout is what most people mean when they say 'timeline.' Events stack top to bottom, a line runs down the left side, and each item hangs off a dot. The tricky part is that 2px connector line — it needs to start at the first dot's center and end at the last dot's center, not at the container's top and bottom edges.

The cleanest approach uses a pseudo-element on the container (or a real div with absolute positioning) rather than trying to set border-left on each item. That way the line is one element you fully control.

Here's a working vertical timeline in React with Tailwind:

const events = [
  { id: 1, date: 'Jan 2026', title: 'Project kickoff', description: 'Scope finalized, repo created.' },
  { id: 2, date: 'Mar 2026', title: 'Beta launch', description: 'First 500 users onboarded.' },
  { id: 3, date: 'Jun 2026', title: 'v1.0 shipped', description: 'Full feature set live.' },
];

export function VerticalTimeline() {
  return (
    <div className="relative pl-8">
      {/* Connector line */}
      <div className="absolute left-3 top-2 bottom-2 w-0.5 bg-neutral-200 dark:bg-neutral-700" />

      <ul className="space-y-10">
        {events.map((event) => (
          <li key={event.id} className="relative flex gap-6">
            {/* Dot */}
            <span className="absolute -left-5 mt-1 flex h-4 w-4 items-center justify-center rounded-full bg-indigo-600 ring-4 ring-white dark:ring-neutral-900" />

            <div>
              <p className="text-xs font-semibold uppercase tracking-wide text-indigo-500">
                {event.date}
              </p>
              <h3 className="mt-1 text-base font-semibold text-neutral-900 dark:text-white">
                {event.title}
              </h3>
              <p className="mt-1 text-sm text-neutral-500">{event.description}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

The dot is absolute -left-5 — that 20px offset (1.25rem) lines it up with the connector line sitting at left-3 (12px from the container edge). Adjust pl-8 on the wrapper if you want more breathing room. It's just arithmetic.

Horizontal Timeline: When You've Got the Width

Horizontal timelines work great in hero sections, step-by-step onboarding flows, and dashboards where you've got full viewport width. They fall apart on mobile, which means you're almost always writing a responsive variant that switches to vertical below md:. Plan for that from day one.

The layout flips: items sit in a flex row, the connector is a horizontal line, and dots sit above or below the text. One common gotcha — the connector line needs to be vertically centered on the dots, not on the entire card. If your cards have variable-height descriptions, centering the line on the container will look off.

export function HorizontalTimeline() {
  return (
    <div className="relative hidden md:block">
      {/* Horizontal connector */}
      <div className="absolute left-0 right-0 top-4 h-0.5 bg-neutral-200 dark:bg-neutral-700" />

      <ol className="relative flex justify-between">
        {events.map((event) => (
          <li key={event.id} className="flex flex-col items-center text-center">
            <span className="relative z-10 mb-4 flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white">
              {event.id}
            </span>
            <p className="text-xs text-indigo-500 font-semibold uppercase">{event.date}</p>
            <h3 className="mt-1 text-sm font-semibold text-neutral-900 dark:text-white max-w-[120px]">
              {event.title}
            </h3>
          </li>
        ))}
      </ol>
    </div>
  );
}

In practice, top-4 on the connector (16px) works because the dot is h-8 w-8 (32px), so half is 16px. This only holds if every dot is the same size — which they should be. If you start mixing icon sizes, you'll need to measure more carefully.

Quick aside: pair this with an overflow scroll wrapper on smaller screens (overflow-x-auto) if you can't collapse to vertical. It's not ideal UX, but it beats a broken layout.

The Alternating Layout: More Visual Punch

Alternating timelines — items zigzag left and right of a center line — look polished and are common in portfolio sites, company history pages, and feature showcases. They're also the most CSS-intensive of the three.

The key insight: use CSS Grid with a three-column layout (1fr auto 1fr). The center column holds the connector line and dots. Even-numbered items go in column 1 (left), odd-numbered in column 3 (right). You can do this with nth-child or by computing a side prop from the index.

export function AlternatingTimeline() {
  return (
    <div className="relative">
      {/* Center line */}
      <div className="absolute left-1/2 top-0 bottom-0 w-0.5 -translate-x-1/2 bg-neutral-200 dark:bg-neutral-700" />

      <ul className="space-y-12">
        {events.map((event, i) => {
          const isLeft = i % 2 === 0;
          return (
            <li
              key={event.id}
              className={`relative flex items-start gap-8 ${
                isLeft ? 'flex-row' : 'flex-row-reverse'
              }`}
            >
              {/* Content card */}
              <div className="w-[calc(50%-2rem)] rounded-xl border border-neutral-200 bg-white p-5 shadow-sm dark:border-neutral-700 dark:bg-neutral-800">
                <p className="text-xs font-semibold uppercase tracking-wide text-indigo-500">
                  {event.date}
                </p>
                <h3 className="mt-1 font-semibold text-neutral-900 dark:text-white">
                  {event.title}
                </h3>
                <p className="mt-1 text-sm text-neutral-500">{event.description}</p>
              </div>

              {/* Dot */}
              <span className="absolute left-1/2 mt-5 flex h-4 w-4 -translate-x-1/2 rounded-full bg-indigo-600 ring-4 ring-white dark:ring-neutral-900" />

              {/* Spacer for the other side */}
              <div className="w-[calc(50%-2rem)]" />
            </li>
          );
        })}
      </ul>
    </div>
  );
}

Look, the w-[calc(50%-2rem)] trick is doing the heavy lifting — it leaves 4rem (64px) total in the center for the dot and line, and the card fills the rest. You'd need to tweak that value if you want a wider center gutter.

Animating Timeline Items on Scroll

A static timeline gets the job done. An animated one where each item fades in as you scroll — that's what actually ends up in production. The 2024-era way to do this is the Intersection Observer API, no scroll event listeners needed.

Wrap each timeline item in a component that adds an is-visible class when the element enters the viewport. Combine with Tailwind's transition utilities and a starting opacity-0 translate-y-4 state, and you've got a clean staggered entrance.

import { useEffect, useRef, useState } from 'react';

function AnimatedItem({ children, delay = 0 }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) setVisible(true); },
      { threshold: 0.1 }
    );
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div
      ref={ref}
      style={{ transitionDelay: `${delay}ms` }}
      className={`transition-all duration-500 ${
        visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
      }`}
    >
      {children}
    </div>
  );
}

Pass delay={i * 150} to stagger the items — 150ms apart feels snappy without being slow. Go above 200ms per item and it starts feeling like you're watching paint dry. You can also check out how Empire UI handles scroll-driven effects by exploring the glassmorphism components — several of them use the same Intersection Observer pattern.

Worth noting: if you're targeting users who prefer reduced motion, wrap the animation styles in a prefers-reduced-motion check or use Tailwind's motion-safe: variant. Don't skip this — it's a real accessibility concern.

Styling Variants and Design System Integration

Once your base timeline works, you'll want to theme it. The most practical approach is a variant prop that switches Tailwind classes — default, minimal, and accent cover 90% of use cases.

If you're building on top of Empire UI, the design tokens you're already using for glassmorphism components or other parts of the component library translate directly. The indigo-600 accent color, the ring-4 dot treatment, the neutral-200 connector lines — these all stay consistent with the rest of your UI if you're pulling from the same Tailwind config.

One more thing — don't hardcode dark mode colors without checking contrast. That dark:bg-neutral-800 card needs at least 4.5:1 contrast ratio against your text. A quick run through the browser DevTools accessibility checker in Chrome 124+ will catch most issues before they ship.

For inspiration on how these timelines fit into full page templates, browse the templates section — several industry templates use timeline components in about-us and roadmap sections.

Responsive Strategies and Final Gotchas

Responsive timelines have one universal rule: horizontal layouts must collapse to vertical on small screens. Below 768px, nobody wants to scroll a horizontal timeline. Use md: breakpoint variants to toggle between layouts, and consider hiding the horizontal version entirely with hidden md:block rather than trying to squash it.

The alternating layout has its own collapse behavior. On mobile it becomes a left-aligned vertical timeline — you drop the flex-row-reverse and shift the center line to the left edge. This means your component needs two distinct rendering modes, not just a CSS tweak.

A few other gotchas worth calling out: connecting lines break when items have overflow: hidden on a parent (this kills absolute positioning); dot rings rendered with box-shadow instead of Tailwind's ring-* utilities lose clarity on retina screens; and if you're server-rendering (Next.js), the Intersection Observer setup needs to be inside useEffect or you'll get hydration mismatches.

If you want to go deeper on component patterns across different visual styles — neobrutalism borders, glassmorphism backgrounds, cyberpunk accents — the full Empire UI component library has production-ready examples you can drop straight into your project.

FAQ

Should I use a library like react-chrono or build a timeline component from scratch?

For simple use cases, build from scratch — you'll write less code than fighting a library's customization API. Reach for react-chrono only if you need complex features like media embeds or keyboard navigation out of the box.

How do I make an alternating timeline collapse properly on mobile?

Remove the flex-row-reverse logic below your md: breakpoint and shift the connector line to the left edge — treat it as a standard vertical timeline. Two separate rendering paths is cleaner than trying to CSS-hack the alternating layout into a vertical one.

Can I use CSS Grid instead of Flexbox for timeline layouts?

Yes, and for the alternating variant Grid is actually the better choice. A three-column grid (1fr auto 1fr) gives you clean control over the center connector column without absolute positioning hacks.

What's the best way to animate timeline items without a library?

Intersection Observer with a CSS transition is all you need — no GSAP, no Framer Motion. Set an initial opacity-0 and translate-y-4 state, then toggle a visible class when the element enters the viewport.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Pricing Table React Component: 3-Tier, Annual Toggle, HighlightNavbar Design Patterns in React: 6 Architectures That ScaleTimeline Component in Tailwind: Vertical, Alternating, Compact10 Tailwind Component Patterns Every Developer Should Know