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 motion

Add 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:

components/ui/lifeline.tsx
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

PropTypeRequiredDefaultDescription
years{ year: string; entries: LifelineEntry[] }[]Yes—Years in display order, newest first is typical. Entries have title, description, and optional detail and href.
defaultOpenYearstringNo—Year open on first render. Defaults to the first year.
multiplebooleanNofalseAllow several years open at once.
currentYearstringNo—Marks a year with a pulsing dot and a Now tag.
classNamestringNo—Classes for the wrapper.

Best Practices

  1. Lead each year with its most important milestone; that's what shows when the year is closed.
  2. Use detail for a season, role or place, and keep descriptions to a sentence or two.
  3. Entries with href open in a new tab and show an arrow on hover.
  4. Reduced motion keeps the spine full and opens years without the slide or blur.