How to build a click-to-reveal timeline section in React
An interactive timeline in React renders a vertical list of clickable year buttons on the left and an animated detail panel on the right. A spring-driven line grows as the user advances through events; AnimatePresence swaps the panel content with a horizontal slide on each click.
- Stack: React + Framer Motion 11 + CSS custom properties, ~78 lines, no extra dependencies.
- State: a single `active` index drives both the progress line height and the panel content.
- Animation: `type: spring` for the line, `AnimatePresence mode='wait'` for the slide-in/out panel.
- Accessible: buttons with onClick, keyboard-navigable; the active dot scales up for visual focus.
- Supports 3 to 6 events; below 3, the progress line effect loses its storytelling impact.
This component turns a company history into an interactive narrative. The left column lists milestone years as buttons; clicking one grows a spring-animated accent line to that point and slides a detail card into the right panel. It replaces a static list with a pace the visitor controls.
Anatomy
The layout is a two-column CSS grid (1fr / 2fr). The left column holds a relative container with a static grey line and a spring-animated accent line overlaid on top, both absolutely positioned. Each event renders as a native button containing a motion.div dot (the selector) and the year + title text. The right column is a card with a fixed min-height that hosts the AnimatePresence panel; a large semi-transparent year number sits at the top as a decorative watermark.
How it works
The line height is derived from `(active / Math.max(events.length - 1, 1)) * 100` percent, animated with `type: 'spring', stiffness: 80, damping: 20` so it eases to the new position on click. Each dot's background and scale toggle based on whether its index is at or below `active`, giving a 'filled so far' read. On the right, `AnimatePresence mode='wait'` wraps a `motion.div` keyed to `active`; it enters from `x: 30` and exits to `x: -30`, so the transition reads as a forward scrub through time.
How to build it in React
Set up the grid and the vertical line
Create a two-column grid container. Inside the left column, add a relative wrapper and absolutely position a grey line (full height) and an accent motion.div (variable height). Both share the same `left`, `top`, and `width` values; only the accent line gets `animate={{ height }}` driven by the active index.
const lineBase = { position: "absolute", left: 7, top: 4, width: 2, borderRadius: 1 }; // Static track <div style={{ ...lineBase, bottom: 4, background: "var(--color-border)" }} /> // Animated fill <motion.div animate={{ height: `${(active / Math.max(events.length - 1, 1)) * 100}%` }} transition={{ type: "spring", stiffness: 80, damping: 20 }} style={{ ...lineBase, background: "var(--color-accent)" }} />Render clickable event buttons with animated dots
Map over events and render a native button for each. Inside, place a motion.div dot positioned absolutely to align with the vertical line. Animate its `scale` (1 or 1.3) and `background` (accent when at or before active, border colour otherwise) so the filled/unfilled state is immediately readable.
<motion.div animate={{ scale: i === active ? 1.3 : 1, background: i <= active ? "var(--color-accent)" : "var(--color-border)", }} transition={{ type: "spring", stiffness: 300, damping: 20 }} style={{ position: "absolute", left: "-2rem", width: 14, height: 14, borderRadius: "50%" }} />Swap the detail panel with AnimatePresence
In the right column, wrap a motion.div in AnimatePresence with `mode='wait'`. Key the motion.div to `active` so React unmounts the old content before mounting the new one. Use `initial={{ opacity: 0, x: 30 }}`, `animate={{ opacity: 1, x: 0 }}`, `exit={{ opacity: 0, x: -30 }}` for the forward-scrub feel.
<AnimatePresence mode="wait"> {current && ( <motion.div key={active} initial={{ opacity: 0, x: 30 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -30 }} transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }} > <span style={{ fontSize: "3rem", fontWeight: 900, opacity: 0.2 }}>{current.year}</span> <h3>{current.title}</h3> <p>{current.description}</p> </motion.div> )} </AnimatePresence>Wire the state and pass data
A single `useState(0)` index is all the state you need. Pass an `events` array (year, title, description) as a prop with default data baked in. On mobile the two-column grid collapses to single column via a media query or a responsive grid fallback; the line stays functional but the split disappears.
When to use it
Reach for this pattern on About pages for companies, agencies, or startups that have a meaningful chronology: funding rounds, product launches, office openings, pivots. It works best with 4 to 6 milestones where each event has a genuine story to tell. Skip it if your company history is shorter than 3 events (the animated line loses its effect) or if the page is already dense with sections competing for attention. On mobile the split layout collapses, so verify the single-column rendering looks intentional.
Used by
- Stripe, Uses milestone-based storytelling on its About page to walk through the company's growth from 2010 to the present.
- Linear, Presents company history as a progressive narrative where each chapter is a distinct milestone, mirroring this split-panel approach.
- Notion, Interactive about page with year-anchored milestones and expandable content panels for each era of the company.
FAQ
How many events work best?
Between 4 and 6. Below 3, the progress line animation feels pointless because there is almost nothing to fill. Above 6, the left column gets crowded and the dots are hard to tap on mobile.
Can I auto-play through the events?
Yes. Add a useEffect with a setInterval that increments `active` up to `events.length - 1`, then clears on unmount. Pause on hover by clearing the interval in onMouseEnter and restarting it in onMouseLeave.
Why does the line use a percentage height instead of pixels?
Percentage makes the calculation independent of the actual rendered height of the left column, which varies with content. The formula `active / (events.length - 1) * 100%` always reaches the last dot exactly, regardless of how many events or how tall the text is.
Does the layout work on mobile?
The component is marked responsive in its metadata, but the 1fr/2fr grid needs an explicit breakpoint to collapse to a single column. Add a CSS media query or swap to `gridTemplateColumns: '1fr'` below 640px. The timeline logic and animations work unchanged in single-column mode.