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

Timeline Component in Tailwind: Vertical, Alternating, Compact

Build vertical, alternating, and compact timeline components with Tailwind CSS — no extra libraries, just utility classes and clean React patterns.

Vertical timeline layout with milestone markers on dark background

Why Tailwind Is Actually Great for Timelines

Timelines look deceptively simple — a line, some dots, some cards. But getting the connector line to stay perfectly aligned with your dot markers across every screen size? That's where things get messy fast. Tailwind's utility-first approach makes this surprisingly manageable because you're working directly with the box model instead of fighting cascade specificity.

The core trick is relative + absolute positioning on the connector line, which you'll see in every implementation here. Worth noting: Tailwind v3.3 introduced the group-has variant which comes in handy when you want to style a connector differently based on whether the adjacent card is expanded or collapsed.

In practice, you don't need @apply for any of this. The class lists look long in your JSX, but they're scannable and predictable — far more so than hunting through a 400-line SCSS file to figure out why your dots are 2px off-center.

Look, if you're already using Tailwind for the rest of your UI, there's no good reason to pull in a dedicated timeline library. What you'd gain in brevity you'd lose in flexibility and bundle size. Let's build the three variants you'll actually ship.

The Vertical Timeline (Classic Left-Aligned)

This is the workhorse. Left-aligned dot on a vertical line, card content sitting to the right. You'll use this for changelogs, roadmaps, and activity feeds. The structure is a ul with each li being relative so you can absolutely position the connector line using a pseudo-element — except in Tailwind, you do it with a real div since arbitrary pseudo-element content isn't available without custom CSS.

Here's the full component:

type TimelineItem = {
  date: string;
  title: string;
  description: string;
  status?: 'done' | 'active' | 'upcoming';
};

const statusStyles = {
  done: 'bg-violet-500 border-violet-400',
  active: 'bg-white border-violet-400 ring-4 ring-violet-500/30',
  upcoming: 'bg-zinc-700 border-zinc-500',
};

export function VerticalTimeline({ items }: { items: TimelineItem[] }) {
  return (
    <ul className="relative ml-4">
      {items.map((item, i) => (
        <li key={i} className="relative pl-8 pb-10 last:pb-0">
          {/* connector line */}
          {i < items.length - 1 && (
            <div className="absolute left-[5px] top-5 h-full w-px bg-zinc-700" />
          )}
          {/* dot */}
          <div
            className={`absolute left-0 top-1.5 h-3 w-3 rounded-full border-2 ${
              statusStyles[item.status ?? 'upcoming']
            }`}
          />
          {/* content */}
          <span className="text-xs font-medium text-zinc-400">{item.date}</span>
          <h3 className="mt-1 text-sm font-semibold text-white">{item.title}</h3>
          <p className="mt-1 text-sm text-zinc-400">{item.description}</p>
        </li>
      ))}
    </ul>
  );
}

The left-[5px] arbitrary value is the key detail — it centers the 1px line under a 12px dot (h-3 w-3 = 12px). If you bump the dot to h-4 w-4, adjust to left-[7px]. Small thing, but getting this wrong makes the whole component look broken.

That said, you'll want to handle the active ring carefully in dark themes. The ring-violet-500/30 opacity modifier keeps the glow subtle without blowing out the background. If you're building a glassmorphism-adjacent UI, check out the glassmorphism components on Empire UI — the card styles there pair well with this timeline structure.

Alternating Timeline for Visual Impact

Alternating timelines put odd items left, even items right. They're popular in landing pages and about-us sections — the kind of thing product designers love and engineers dread. The zigzag layout is genuinely harder to pull off in pure CSS without flexbox tricks.

The approach: use flex on each item with flex-row or flex-row-reverse based on index. The center line lives in a parent wrapper with before: pseudo-element — or again, a positioned div for Tailwind compatibility.

export function AlternatingTimeline({ items }: { items: TimelineItem[] }) {
  return (
    <div className="relative">
      {/* center line */}
      <div className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-zinc-700" />

      <div className="space-y-12">
        {items.map((item, i) => {
          const isEven = i % 2 === 0;
          return (
            <div
              key={i}
              className={`flex items-center gap-8 ${
                isEven ? 'flex-row' : 'flex-row-reverse'
              }`}
            >
              {/* card */}
              <div className="flex-1">
                <div
                  className={`rounded-xl border border-zinc-700/60 bg-zinc-900 p-5 ${
                    isEven ? 'text-right' : 'text-left'
                  }`}
                >
                  <span className="text-xs text-zinc-500">{item.date}</span>
                  <h3 className="mt-1 font-semibold text-white">{item.title}</h3>
                  <p className="mt-2 text-sm text-zinc-400">{item.description}</p>
                </div>
              </div>

              {/* center dot */}
              <div className="relative z-10 h-4 w-4 flex-shrink-0 rounded-full border-2 border-violet-400 bg-violet-500" />

              {/* spacer */}
              <div className="flex-1" />
            </div>
          );
        })}
      </div>
    </div>
  );
}

Honestly, the spacer div is the ugly part of this pattern. It's a deliberate empty div to mirror the card on the opposite side so the dot stays centered. There's no clean way around it without CSS Grid, which I'll cover in a minute.

Quick aside: on mobile, you'll want to collapse this to a single-column layout. Add a sm:flex-row approach and fall back to a stacked list below your breakpoint — don't try to render the alternating layout at 375px width, it just doesn't work.

One more thing — the z-10 on the dot is required. Without it, the center line div stacks above the dot and you end up with a line running through the middle of your marker. Took me longer to debug that than I'd like to admit.

Compact Timeline with CSS Grid

The compact variant is what you reach for in sidebars, notifications panels, or anywhere vertical space matters. Less padding, smaller dots, tighter typography. Grid makes this cleaner than the flex approach for the standard vertical layout.

export function CompactTimeline({ items }: { items: TimelineItem[] }) {
  return (
    <div className="grid grid-cols-[20px_1fr] gap-x-3">
      {items.map((item, i) => (
        <>
          {/* left column: dot + line */}
          <div key={`left-${i}`} className="flex flex-col items-center">
            <div className="mt-1.5 h-2.5 w-2.5 flex-shrink-0 rounded-full border border-violet-400 bg-violet-500" />
            {i < items.length - 1 && (
              <div className="mt-1 flex-1 w-px bg-zinc-700" />
            )}
          </div>

          {/* right column: content */}
          <div key={`right-${i}`} className="pb-6 last:pb-0">
            <p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500">
              {item.date}
            </p>
            <p className="mt-0.5 text-sm font-medium text-zinc-100">{item.title}</p>
            {item.description && (
              <p className="mt-1 text-xs text-zinc-500">{item.description}</p>
            )}
          </div>
        </>
      ))}
    </div>
  );
}

The grid-cols-[20px_1fr] arbitrary column value gives you a fixed 20px column for the dot/line area. The line itself is a flex-1 div inside a flex column — so it stretches to fill exactly the vertical gap between dots. This is cleaner than the absolute positioning approach because you never have to fiddle with magic pixel offsets.

The text-[11px] size for dates is intentional. At 12px (Tailwind's text-xs) the date label competes with the title. Drop it to 11px and add uppercase tracking-wide and it reads as metadata, not content.

Worth noting: if you're building this into a dashboard layout, check the tailwind dashboard layout article for how to integrate compact components into multi-column grid systems.

Animated Connectors and Progress States

A static timeline is fine. An animated one that fills the connector line as users scroll through content? That's the kind of detail people remember. You can do this with Framer Motion or with the Intersection Observer API — no JS animation library required.

Here's a Tailwind-compatible approach using CSS custom properties set from JS. The connector line gets its height from a --progress variable you update as the user scrolls:

import { useEffect, useRef } from 'react';

export function AnimatedTimeline({ items }: { items: TimelineItem[] }) {
  const lineRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleScroll = () => {
      if (!containerRef.current || !lineRef.current) return;
      const { top, height } = containerRef.current.getBoundingClientRect();
      const scrolled = Math.max(0, Math.min(1, (window.innerHeight - top) / height));
      lineRef.current.style.height = `${scrolled * 100}%`;
    };
    window.addEventListener('scroll', handleScroll, { passive: true });
    handleScroll();
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <div ref={containerRef} className="relative ml-4">
      {/* background line */}
      <div className="absolute left-[5px] top-0 h-full w-px bg-zinc-800" />
      {/* animated progress line */}
      <div
        ref={lineRef}
        className="absolute left-[5px] top-0 w-px bg-violet-500 transition-none"
        style={{ height: '0%' }}
      />
      {/* items */}
      <ul className="relative">
        {items.map((item, i) => (
          <li key={i} className="relative pl-8 pb-10 last:pb-0">
            <div className="absolute left-0 top-1.5 h-3 w-3 rounded-full border-2 border-violet-400 bg-violet-500" />
            <span className="text-xs text-zinc-400">{item.date}</span>
            <h3 className="mt-1 text-sm font-semibold text-white">{item.title}</h3>
            <p className="mt-1 text-sm text-zinc-400">{item.description}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Disable Tailwind's transition-none on the progress line — you don't want CSS transitions fighting the scroll handler. The effect is smoothed by the scroll itself.

Honestly, this gets you 90% of what people pay for in dedicated animation libraries. If you want the remaining 10% — spring physics, stagger reveals per item — that's when you reach for Framer Motion. But for a marketing page or changelog? This works perfectly.

If you're building a feature-rich roadmap view, combining this with the stepper component pattern gives you interactive milestone control on top of the visual progress line.

Responsive Behavior and Mobile Considerations

The alternating layout breaks below 640px. Full stop. You need to detect this and switch to single-column. The cleanest way is conditional rendering off a useMediaQuery hook rather than trying to handle it entirely with CSS — Tailwind's responsive modifiers work great for styling, but fundamentally swapping flex-row to flex-col changes your DOM structure enough that the CSS approach gets convoluted.

function useIsDesktop() {
  const [isDesktop, setIsDesktop] = React.useState(
    () => typeof window !== 'undefined' && window.innerWidth >= 640
  );
  useEffect(() => {
    const mq = window.matchMedia('(min-width: 640px)');
    const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);
  return isDesktop;
}

// Then in your component:
const isDesktop = useIsDesktop();
return isDesktop
  ? <AlternatingTimeline items={items} />
  : <VerticalTimeline items={items} />;

Touch targets are the other thing people get wrong. Your dot markers are 12px by default. On mobile, you need at least 44px tap targets — the 2019 WCAG 2.1 success criterion 2.5.5 covers this explicitly. Wrap each dot in a visually invisible 44px hit area using padding or a pseudo-element.

For the compact sidebar variant, mobile isn't much of an issue since it's already single-column. Just watch your left padding — pl-8 (32px) eats into narrow viewports fast. Drop to pl-6 at sm: breakpoint.

One more thing — test your timeline in RTL mode if your app supports Arabic, Hebrew, or Persian. The connector line needs start-[5px] instead of left-[5px] in Tailwind v3.3+ using logical properties. Worth doing upfront rather than retrofitting.

Styling Variants: Dark, Colored, and Glassmorphic

Dark mode is the default assumption in most modern dashboards, but you'll need light variants too. Rather than duplicating component code, use Tailwind's dark: modifier and a data-theme attribute approach so you can preview both in Storybook without messing with prefers-color-scheme.

Colored connector lines — gradient from violet-500 to blue-500 — are a quick win that makes timelines feel premium. Just add a bg-gradient-to-b from-violet-500 to-blue-500 to the line div. Looks especially good in roadmap contexts.

For a frosted glass card treatment on each timeline entry, you're looking at backdrop-blur-md bg-white/5 border border-white/10 rounded-xl. That combination reads as glassmorphism without the harsh ring artifacts you get from heavy blur values. The glassmorphism generator lets you dial in the exact blur and opacity values before you commit them to code.

// Glassmorphic timeline card
<div className="rounded-xl border border-white/10 bg-white/5 p-5 backdrop-blur-md">
  <span className="text-xs text-white/40">{item.date}</span>
  <h3 className="mt-1 font-semibold text-white">{item.title}</h3>
  <p className="mt-2 text-sm text-white/60">{item.description}</p>
</div>

If you want to go full Empire UI with pre-built styled components rather than assembling from scratch, browse components — there are several timeline and feed patterns in the library that are already animated and accessible out of the box.

FAQ

Can I build a timeline in Tailwind without writing custom CSS?

Yes. The vertical and compact variants use only utility classes. The alternating layout gets a bit hairy at mobile breakpoints, but you can handle that with conditional rendering rather than raw CSS.

How do I make the connector line only fill up to the last completed item?

Track a completedIndex prop and conditionally swap the line color from bg-violet-500 to bg-zinc-700 on segments past that index. It's a per-segment check, not a single full-height line.

Is there a Tailwind plugin for timelines?

Not one worth using. The few that exist are thin wrappers that add more abstraction than value. The patterns in this article are 30-50 lines of JSX — simpler to own directly.

What's the best approach for an animated timeline that responds to scroll position?

A scroll event listener updating a div's inline height style works well and keeps your bundle lightweight. Use Framer Motion's useScroll + scaleY if you need spring-eased fills or per-item stagger animations.

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

Read next

Building a Landing Page in Tailwind CSS: Section by SectionE-Commerce Product Page in Tailwind: Gallery, Options, CTATimeline Component in React: Vertical, Horizontal, AlternatingFooter Design in React: 5 Patterns From Minimal to Full-Featured