Animated Timeline with Game of Life

A career timeline where each month is a cell in a modified Game of Life

The career duration counter at the top of the page has a hover tooltip with a tiny animated grid — each colored square is one month at a company, and a modified Conway's Game of Life makes the cells shimmer while you look at them. Static colored bars would have been fine, but this is more fun.

Here it is running against my actual career data — each color is a different company:

The grid

Each cell in the canvas represents one month of employment. I walk through the career data, calculate the month count between start and end dates, and lay them out left-to-right, top-to-bottom. The color comes from the job's hexColor.

animated-timeline.tsx
1 // Precompute cell layout: which cells exist and their colors
2 const cells: CellInfo[] = [];
3 let currentMonth = 0;
4 careerData.forEach((job) => {
5 const months = getMonthsBetweenDates(job.startDate, job.endDate);
6 for (let i = 0; i < months; i++) {
7 const row = Math.floor(currentMonth / SQUARES_PER_ROW);
8 const col = currentMonth % SQUARES_PER_ROW;
9 if (row < SQUARES_PER_COL) {
10 cells.push({ row, col, hexColor: job.hexColor });
11 }
12 currentMonth++;
13 }
14 });

This runs once on mount, not every frame. The animation loop just iterates over the precomputed cells array.

Modified Game of Life rules

Standard Conway rules (survive with 2-3 neighbors, birth with exactly 3) produce dramatic oscillations — whole regions flicker on and off. I wanted something calmer, more like breathing. So I loosened the survival range and kept birth tight:

  • Survival: 1-4 neighbors (standard is 2-3)
  • Birth: 2-3 neighbors (standard is exactly 3)
  • Spontaneous activation: 0.1% chance per dead cell per generation, to prevent stagnation
animated-timeline.tsx
1 if (isAlive) {
2 newGrid[row][col] = neighbors >= 1 && neighbors <= 4;
3 } else {
4 newGrid[row][col] = neighbors >= 2 && neighbors <= 3;
5 }
6
7 // Prevent total extinction
8 if (!newGrid[row][col] && Math.random() < 0.001) {
9 newGrid[row][col] = true;
10 }

The wider survival window means cells tend to stay alive once they appear, and clusters grow slowly rather than exploding. Combined with the birth range overlapping survival, you get a gentle drift of activity across the grid.

Frame throttling

The animation runs at 60fps for smooth pulsing, but the Game of Life simulation only advances every 30 frames (~0.5 seconds). Without this, the automaton would churn through generations too fast to see, and the grid would just look like static noise.

animated-timeline.tsx
1 const FRAMES_PER_GENERATION = 30;
2
3 const animate = (timestamp: DOMHighResTimeStamp) => {
4 frameCount++;
5 const shouldUpdateGrid = frameCount % FRAMES_PER_GENERATION === 0;
6
7 // Only copy grid when we need to update it
8 let newGrid: boolean[][] | null = null;
9 if (shouldUpdateGrid) {
10 newGrid = grid.map((arr) => [...arr]);
11 }
12
13 // ... draw every frame, but only run GoL logic on generation ticks
14 };

The grid copy is also conditional — no point allocating a new 2D array 59 out of every 60 frames when you're not going to write to it.

The pulse overlay

Alive cells get a translucent overlay whose opacity oscillates with a sine wave. This is what makes the grid feel like it's breathing rather than just flipping cells on and off.

An early version used a white overlay, which looked fine on light backgrounds but washed dark colors out to grey in dark mode. Uber's black cells just looked like dirty grey squares. The fix: precompute a highlight color per job by mixing the base hex 60% toward white, then use that as the overlay instead of pure white. Dark cells get a brighter tint of themselves rather than a grey wash.

animated-timeline.tsx
1 // Precompute highlight color: mix 60% toward white
2 const r = parseInt(job.hexColor.slice(1, 3), 16);
3 const g = parseInt(job.hexColor.slice(3, 5), 16);
4 const b = parseInt(job.hexColor.slice(5, 7), 16);
5 const hlR = Math.round(r + (255 - r) * 0.6);
6 const hlG = Math.round(g + (255 - g) * 0.6);
7 const hlB = Math.round(b + (255 - b) * 0.6);
8
9 // In the render loop:
10 if (grid[row][col]) {
11 const alpha = Math.sin(timeSec * 2) * 0.3 + 0.4;
12 ctx.fillStyle = `rgba(${hlR}, ${hlG}, ${hlB}, ${alpha})`;
13 ctx.fillRect(x, y, SQUARE_SIZE, SQUARE_SIZE);
14 }

The timeSec comes from the DOMHighResTimeStamp that requestAnimationFrame passes to its callback — no manual time accumulation needed. The alpha swings between 0.1 and 0.7, so even at its dimmest the overlay is still visible.

Theme awareness

Canvas doesn't participate in CSS — you have to read theme values yourself. I read --gray1 from getComputedStyle on mount and re-read it whenever the site's theme-change event fires. That variable is the page background in both light and dark mode, so the canvas blends in seamlessly.

animated-timeline.tsx
1 let bgColor = "#FFFFFF";
2 const readBgColor = () => {
3 const styles = getComputedStyle(document.documentElement);
4 const value = styles.getPropertyValue("--gray1").trim();
5 bgColor = value || "#FFFFFF";
6 };
7 readBgColor();
8
9 // Re-read when theme toggle fires or OS preference changes
10 window.addEventListener("theme-change", () => readBgColor());
11 const media = window.matchMedia("(prefers-color-scheme: dark)");
12 media.addEventListener("change", () => readBgColor());