Building an Interactive Timeline Component
Deep dive into creating a custom SVG-based timeline component with hover interactions, dynamic positioning, and responsive year labels
Building an Interactive Timeline Component
I built a custom timeline component for my about page to visualize my development journey from 2019 to 2026. What started as a simple idea turned into an interesting exploration of SVG manipulation, React state management, and spatial UI interactions. Here's how I built it and the bugs I encountered along the way.
What It Does
The timeline shows project milestones chronologically with these features:
- Year markers (26, 25, 24...) positioned to the left in Sekuya font
- Tick marks of varying lengths: years (36px), half-years (24px), months (12px)
- Purple-colored tick marks indicate events/projects
- Hover over purple marks to extend the tick mark and reveal project cards
- Multiple projects in the same month stack vertically with 240px spacing
- Cards show year, title, and description with styled backgrounds
Architecture Overview
The component is built with React, Emotion CSS, and SVG. The key architectural decisions:
SVG-Based Timeline
I chose SVG over HTML/CSS because it gives precise control over positioning and allows tick marks to extend dynamically on hover without layout shifts. The viewBox is set to -40 65 290 1490to accommodate year labels on the left while keeping the timeline content properly bounded.
// Timeline spans 2019-2026, positions calculated from dates
const getYPosition = (year: number, month: number): number => {
const startYear = 2019;
const startMonth = 1;
const totalMonths = (2026 - 2019) * 12;
const timelineHeight = 1490;
const monthsFromStart = (year - startYear) * 12 + (month - startMonth);
return 1555 - (monthsFromStart / totalMonths) * timelineHeight;
};Every milestone gets a Y position calculated from its actual date. This makes the spacing proportional to time rather than arbitrary.
Hover Interaction System
I use React state to track which timeline position is being hovered. Each tick mark that has events gets an invisible 30x30px rect as a hover target:
const [hoveredPosition, setHoveredPosition] = useState<number | null>(null);
// In the tick mark rendering:
if (hasEvent) {
const isHovered = hoveredPosition !== null && Math.abs(hoveredPosition - y) < 5;
const finalX2 = isHovered ? 200 : x2;
// Tick mark extends from x1 to finalX2 when hovered
ticks.push(
<line x1={x1} y1={y} x2={finalX2} y2={y} ... />
);
}When hovered, the tick mark extends from its normal length (12-36px) all the way to x=200, creating a visual connection to the card that appears at x=205.
Multi-Card Stacking
Multiple projects in the same month need to stack without overlapping. I calculate vertical offsets based on how many cards share the same Y position:
// In card rendering:
const cardsAtSamePosition = devMilestones.filter(m => Math.abs(m.y - item.y) < 5);
const indexAtPosition = cardsAtSamePosition.findIndex(m => m.title === item.title);
const verticalOffset = indexAtPosition * 240; // 240px spacing between cards
// Position with offset applied
style={{
top: `${item.y + verticalOffset}px`,
left: '205px',
opacity: isHovered ? 1 : 0,
}}Major Bugs and Fixes
Bug #1: Overlapping Tick Mark Lines
Initially, I was drawing the base tick mark AND an extension line on hover, which created a visible artifact where they met. The fix was to make the tick mark's x2 dynamic - it extends all the way to x=200 when hovered instead of drawing two separate lines.
Bug #2: Year Labels Cut Off
The year labels (26, 25, 24...) were positioned at x=-5 but the viewBox started at x=0, so they were being clipped. I adjusted the viewBox to start at x=-40, giving 40px of space for the labels on the left.
Bug #3: Massive Top/Bottom Padding
The SVG had huge empty space at top and bottom because the viewBox was 0-1620 but the actual timeline content only spanned y=65 to y=1555. Fixed by adjusting viewBox to start at y=65 with height=1490, and matching the sideStyles height to 1490px.
Bug #4: Cards Not Spatially Related to Tick Marks
At one point the SVG was 250px wide but the grid column was only 60px, causing a misalignment between where cards appeared and where tick marks extended. The SVG width, viewBox width, and container width all need to be coordinated.
Design Evolution
The component went through several design iterations:
- Center-aligned → Left-aligned: Started with centered tick marks extending both directions, switched to left-aligned for cleaner visual flow
- Dot indicators → Color coding: Removed small indicator dots in favor of making event tick marks purple (hsl 280°)
- Variable thickness → Variable length: All marks are now 2px thick, differentiated only by length
- Year labels: Added abbreviated year labels (26, 25, 24) using Sekuya font for better temporal orientation
- Vertical space reduction: Halved the timeline height from 3000px to 1490px since cards collapse by default
Tech Stack Details
The component uses:
- React: State management with useState for hover tracking and card expansion
- Emotion: CSS-in-JS with css and keyframes for styling and animations
- Next.js Font: Sekuya from next/font/google for year labels
- SVG: All timeline rendering, tick marks, and year labels
- TypeScript: Full type safety with custom TimelineEvent interface
Code Organization
The file structure follows a pattern I use across components:
- Imports and font setup at top
- TypeScript interfaces (TimelineEvent)
- Emotion keyframes for animations
- Emotion CSS styles (container, content, cards, etc)
- Component function with state
- Helper functions (getYPosition)
- Data array (devMilestones)
- JSX rendering logic
Performance Considerations
With 14 milestones and tick marks generated for every month from 2019-2026 (84 months), there are ~100 SVG elements being rendered. The component performs well because:
- SVG is hardware-accelerated by browsers
- Hover state only re-renders affected elements
- Cards use CSS transitions (GPU-accelerated) for opacity changes
- No complex calculations in render - Y positions pre-calculated in data array
What I'd Do Differently
If I were to rebuild this component from scratch with what I know now:
- Start with the viewBox dimensions figured out instead of adjusting them repeatedly
- Use a more systematic approach to coordinate space (SVG space vs container space vs screen space)
- Add debug borders from the beginning to catch spatial issues early
- Consider using a library like D3.js for scale/axis management if the complexity grows
- Add accessibility - keyboard navigation and ARIA labels for screen readers
Conclusion
This component demonstrates that custom timeline UIs don't require heavy libraries - with SVG, React, and some geometric calculations, you can build something both functional and visually interesting. The key is understanding coordinate systems, managing state cleanly, and iterating on the design based on what works visually.
The full component code is available in my site's repository at src/components/Timeline.tsx.