Overview
A personal timeline organised by year. The spine fills as the timeline scrolls past, years fade up as they come into view, and the current year gets a pulsing dot and a Now tag. Closed years show their first milestone and how many more are inside; opening one swaps that for a count, then its milestones rise in along a small branch line. Milestones can carry a tag and a link. Arrow keys, Home and End move between years.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useId, useRef, useState } from "react";import type { KeyboardEvent } from "react";import { AnimatePresence, motion, useReducedMotion, useScroll, useSpring } from "motion/react";import { cn } from "@/lib/utils"; export type LifelineEntry = { title: string; description: string; /** Small tag above the title, e.g. a season or role. */ detail?: string; /** Optional link for the entry. */ href?: string;};export type LifelineYear = { year: string; entries: LifelineEntry[] };export type LifelineProps = { years: LifelineYear[]; defaultOpenYear?: string; /** Allow several years open at once. */ multiple?: boolean; /** Year to mark as "now" with a live dot. */ currentYear?: string; className?: string;}; const EASE_OUT = [0.23, 1, 0.32, 1] as const;const HEIGHT = { type: "spring" as const, bounce: 0.1, duration: 0.5 }; export function Lifeline({ years, defaultOpenYear, multiple = false, currentYear, className }: LifelineProps) { const reduce = !!useReducedMotion(); const uid = useId(); const rootRef = useRef<HTMLElement>(null); const [open, setOpen] = useState<string[]>(() => { const first = defaultOpenYear ?? years[0]?.year; return first ? [first] : []; }); // The spine fills as the timeline scrolls through the viewport. const { scrollYProgress } = useScroll({ target: rootRef, offset: ["start 85%", "end 55%"] }); const fill = useSpring(scrollYProgress, { stiffness: 200, damping: 40 }); const toggle = (year: string) => setOpen((list) => (list.includes(year) ? list.filter((y) => y !== year) : multiple ? [...list, year] : [year])); const onHeaderKey = (e: KeyboardEvent<HTMLButtonElement>) => { const headers = Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>("[data-lifeline-year]") ?? []); const i = headers.indexOf(e.currentTarget); const map: Record<string, number> = { ArrowDown: Math.min(headers.length - 1, i + 1), ArrowUp: Math.max(0, i - 1), Home: 0, End: headers.length - 1, }; if (!(e.key in map)) return; e.preventDefault(); headers[map[e.key]]?.focus(); }; return ( <section ref={rootRef} aria-label="Life timeline" className={cn("relative mx-auto w-full max-w-2xl", className)}> {/* Spine */} <div aria-hidden className="absolute top-3 bottom-3 left-[15px] w-[2px] overflow-hidden rounded-full bg-(--color-rule)"> <motion.div className="h-full w-full origin-top rounded-full bg-(--color-ink)" style={{ scaleY: reduce ? 1 : fill }} /> </div> <div role="list" className="flex flex-col"> {years.map((item, index) => { const isOpen = open.includes(item.year); const isNow = currentYear === item.year; const panelId = `${uid}-panel-${index}`; const headerId = `${uid}-header-${index}`; const lead = item.entries[0]; return ( <motion.div role="listitem" key={item.year} initial={reduce ? false : { opacity: 0, y: 12 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, amount: 0.4 }} transition={{ duration: 0.45, ease: EASE_OUT, delay: reduce ? 0 : Math.min(index * 0.05, 0.3) }} className="relative pl-12" > {/* Year node */} <span aria-hidden className="absolute top-[22px] left-[16px] -translate-x-1/2 -translate-y-1/2"> {isNow && !reduce ? ( <motion.span className="absolute inset-0 rounded-full bg-(--color-ink)" animate={{ scale: [1, 2.4], opacity: [0.35, 0] }} transition={{ duration: 1.8, repeat: Infinity, ease: "easeOut" }} /> ) : null} <motion.span className="relative block rounded-full" initial={false} animate={{ width: isOpen ? 14 : 10, height: isOpen ? 14 : 10, backgroundColor: isOpen || isNow ? "var(--color-ink)" : "var(--color-paper)", }} transition={{ type: "spring", bounce: 0.4, duration: 0.4 }} style={{ boxShadow: "0 0 0 2px var(--color-paper), inset 0 0 0 2px var(--color-ink)" }} /> </span> <button type="button" id={headerId} data-lifeline-year aria-expanded={isOpen} aria-controls={panelId} onClick={() => toggle(item.year)} onKeyDown={onHeaderKey} className={cn( "group -mx-3 flex w-[calc(100%+1.5rem)] items-center gap-4 rounded-[14px] px-3 py-3 text-left outline-none", "transition-colors duration-200 hover:bg-(--color-paper-2)/70", "focus-visible:ring-2 focus-visible:ring-(--color-focus)", )} > <span className={cn( "w-[4.5rem] shrink-0 font-mono text-[15px] tracking-tight tabular-nums transition-colors duration-200 sm:text-[17px]", isOpen ? "text-(--color-ink)" : "text-(--color-neutral) group-hover:text-(--color-ink-2)", )} > {item.year} </span> <span className="min-w-0 flex-1"> <span className="flex items-center gap-2"> <span className="relative grid min-w-0"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={isOpen ? "count" : "lead"} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(3px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} className={cn("truncate text-[14.5px] font-medium", isOpen ? "text-(--color-neutral)" : "text-(--color-ink-2)")} > {/* Open years list every entry below, so the header just counts them. */} {isOpen ? `${item.entries.length} ${item.entries.length === 1 ? "milestone" : "milestones"}` : (lead?.title ?? item.year)} </motion.span> </AnimatePresence> </span> {isNow ? ( <span className="shrink-0 rounded-full bg-(--color-ink) px-1.5 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-(--color-paper) uppercase"> Now </span> ) : null} </span> {item.entries.length > 1 ? ( <AnimatePresence initial={false}> {!isOpen ? ( <motion.span initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }} className="block overflow-hidden text-[12.5px] text-(--color-neutral)" > +{item.entries.length - 1} more </motion.span> ) : null} </AnimatePresence> ) : null} </span> <motion.svg aria-hidden viewBox="0 0 16 16" className="size-4 shrink-0 text-(--color-neutral)" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" animate={{ rotate: isOpen ? 180 : 0 }} transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.3, duration: 0.45 }} > <path d="M4 6l4 4 4-4" /> </motion.svg> </button> <AnimatePresence initial={false}> {isOpen ? ( <motion.div key="panel" id={panelId} role="region" aria-labelledby={headerId} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={reduce ? { duration: 0.15 } : { height: HEIGHT, opacity: { duration: 0.25 } }} className="overflow-hidden" > <div role="list" className="relative flex flex-col gap-1 pt-1 pb-5 pl-[5.5rem] max-sm:pl-0"> {/* Branch line linking this year's entries */} <span aria-hidden className="absolute top-3 bottom-8 left-[4.75rem] w-px bg-(--color-rule) max-sm:hidden" /> {item.entries.map((entry, i) => { const body = ( <> <span aria-hidden className="absolute top-[18px] left-0 size-[7px] -translate-x-1/2 rounded-full bg-(--color-paper) shadow-[inset_0_0_0_1.5px_var(--color-neutral)] max-sm:hidden" /> {entry.detail ? ( <span className="block text-[10.5px] font-semibold tracking-[0.12em] text-(--color-neutral) uppercase">{entry.detail}</span> ) : null} <span className="mt-0.5 block text-[14px] font-medium text-(--color-ink)"> {entry.title} {entry.href ? <span aria-hidden className="ml-1 inline-block transition-transform group-hover/entry:translate-x-0.5 group-hover/entry:-translate-y-0.5">↗</span> : null} </span> <span className="mt-1 block text-[13.5px] leading-relaxed text-pretty text-(--color-ink-2)">{entry.description}</span> </> ); return ( <motion.div role="listitem" key={`${entry.title}-${i}`} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} transition={{ duration: 0.35, ease: EASE_OUT, delay: reduce ? 0 : 0.06 + i * 0.06 }} > {entry.href ? ( <a href={entry.href} target="_blank" rel="noreferrer" className="group/entry relative -mx-3 block rounded-[12px] px-3 py-2.5 no-underline outline-none transition-colors hover:bg-(--color-paper-2)/70 focus-visible:ring-2 focus-visible:ring-(--color-focus)" > {body} </a> ) : ( <div className="relative -mx-3 px-3 py-2.5">{body}</div> )} </motion.div> ); })} </div> </motion.div> ) : null} </AnimatePresence> </motion.div> ); })} </div> </section> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { Lifeline } from "@/components/ui/lifeline"; <Lifeline currentYear="2026" defaultOpenYear="2026" years={[ { year: "2026", entries: [ { title: "Building what comes next", detail: "Now", description: "A current chapter." }, { title: "A new collaboration", detail: "Spring", description: "Short context.", href: "https://example.com" }, ], }, { year: "2025", entries: [{ title: "A meaningful first", description: "Keep entries concise." }] }, ]}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| years | { year: string; entries: LifelineEntry[] }[] | Yes | — | Years in display order, newest first is typical. Entries have title, description, and optional detail and href. |
| defaultOpenYear | string | No | — | Year open on first render. Defaults to the first year. |
| multiple | boolean | No | false | Allow several years open at once. |
| currentYear | string | No | — | Marks a year with a pulsing dot and a Now tag. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Lead each year with its most important milestone; that's what shows when the year is closed.
- Use
detailfor a season, role or place, and keep descriptions to a sentence or two. - Entries with
hrefopen in a new tab and show an arrow on hover. - Reduced motion keeps the spine full and opens years without the slide or blur.