Timeline
I've been thinking a lot about time lately.
Not in a philosophical, what-does-it-all-mean way — more in the sense of how quickly things compound. A year feels long when you're in it, and impossibly short when you look back.
I was born in 2004. That means I've been alive for years as you read this. I've watched the internet go from something you had to dial into to something that lives in your pocket, in your walls, in your glasses.
The component
I wanted to build something that made this feel tangible — not a list of dates, but something you could feel. Something that grows with me automatically, every year, without me having to touch it.
The bars below represent each year of my life. Hover to explore. Click to mark a moment.
The component is built with a simple proximity-based scale model. Each bar scales based on its distance from the hovered index — a technique I've seen in various animation experiments and in the beautiful motion work at Stripe Press.
The core of how it works:
const calculateScale = (index: number, hovered: number | null) => {
if (hovered === null) return 0.4;
const distance = Math.abs(index - hovered);
return Math.max(1 - distance * 0.18, 0.4);
};The 0.18 falloff factor controls how quickly bars shrink away from the hovered one. Lower feels more fluid, higher feels snappier. 0.18 felt right for the number of years I have.
The year range is fully dynamic:
const START_YEAR = 2004;
const currentYear = new Date().getFullYear();
const years = Array.from(
{ length: currentYear - START_YEAR + 1 },
(_, i) => currentYear - i
);Every January 1st, a new bar appears automatically. No deploy needed. The component just knows.
What I noticed building this
The hardest part wasn't the animation — it was deciding what to label each year. Some years have obvious anchors: first computer, first line of code, first shipped app. Others are harder to pin down.
The bars that don't have labels aren't empty years. They're just years where the important things were quieter. Growing up. Reading. Watching. Absorbing.
Why this lives as a blog post
Most portfolios have an "About" page with a timeline. Mine doesn't — or at least, it didn't. I wanted this to live as a piece of writing, not a static page, because the context matters as much as the data.
A bar chart of years is just numbers. But a bar chart of years, next to a paragraph about why you built it, next to a scribble about the years that are hard to name — that's something closer to a story.
The component will keep growing. Every year I'm alive, it gets one bar taller. By the time I'm 40, there'll be 36 bars. By 60, 56. I find that oddly comforting.
Time compounds. So does craft.
Another version
Here's a slightly different take on the same idea. The mechanic is the same: distance from the hovered index maps to a scale value, with a floor so bars never fully disappear. But a few things are different.
This version scales from the center of each bar rather than the left edge. The falloff is slightly steeper (0.2 vs 0.18). The year label blurs in — filter: blur(4px) collapsing to blur(0px) — which gives it a softer feel. And clicking a bar selects it, turning it yellow, which persists as you hover elsewhere.
The selection state is a nice touch. It turns the timeline into something more like a scrubber. I didn't include it in my main version because I wasn't sure what "selecting" a year would mean for me — but it's worth having in the toolkit.
A minimal calculateScale:
const calculateScale = (index: number) => {
if (hoveredIndex === null) return 0.4;
const distance = Math.abs(index - hoveredIndex);
return Math.max(1 - distance * 0.2, 0.4);
};No opacity logic, no sub-labels. Just distance-to-scale, with a floor. If you're building something like this, start simple and layer in complexity only when you need it.