Lifeline: Animated Timeline Component for Next.js

Add a horizontal desktop timeline and vertical mobile timeline to Next.js with editable shadcn source files, media, people, and event data.

Lifeline is a shadcn/ui timeline component registry that allows you to create chronological stories as a horizontal, scroll-scrubbed rail on desktop and a vertical timeline on mobile.

You can use the components for personal histories, company archives, product releases, tournament records, and editorial story pages with event text, links, portraits, logos, images, video, and lightbox media.

Features

  • Scrubs a horizontal timeline with the mouse wheel, trackpad, pointer drag, or arrow keys on desktop.
  • Switches to a vertical scrolling layout below the Tailwind CSS md breakpoint.
  • Animates the rail and markers on first view, with a reduced-motion fallback.
  • Accepts text, links, hover images, looping video previews, badges, company marks, mentors, and people met.
  • Places draggable photo cards along the rail and opens them in a lightbox.
  • Runs as a full-page experience or an embedded module inside a longer page.
  • Installs as an editable local source through the shadcn CLI.

Use Cases

  • A personal portfolio maps education, roles, projects, mentors, and major career changes across a single visual history.
  • Company history pages connect founding dates, product launches, acquisitions, leadership changes, and press images.
  • Product teams document a release sequence with linked announcements, screenshots, videos, and organization marks.
  • Tournament, conference, or tour pages replace calendar years with day labels, rounds, venues, and participant milestones.
  • Editorial features pair archival media with a long chronological narrative and retain a usable mobile layout.

How To Use Lifeline

Prerequisites

Start with a Next.js App Router project that uses Tailwind CSS and has shadcn/ui initialized.

The Lifeline registry item declares lucide-react, next-themes, and geist as package dependencies. It also requests the shadcn utils registry item, which supplies the local cn() helper used throughout the components.

Run the following command if the project does not contain components.json:

npx shadcn@latest init

Install a Complete Timeline Page

The fastest setup adds the component system, personal starter data, page shell, and an App Router route at app/lifeline/page.tsx:

npx shadcn@latest add evilrabbit/lifeline/page

The CLI asks before replacing an existing route file. Decline the overwrite when app/lifeline/page.tsx already contains work that must remain intact.

Choose a Registry Item

Registry ItemCommandResult
Complete pagenpx shadcn@latest add evilrabbit/lifeline/pageAdds the shell, personal starter data, components, and app/lifeline/page.tsx.
Shellnpx shadcn@latest add evilrabbit/lifeline/shellAdds the navigation, stage, footer, and core timeline dependency.
Personal starternpx shadcn@latest add evilrabbit/lifeline/personalAdds the core system and a commented personal timeline data file.
Company starternpx shadcn@latest add evilrabbit/lifeline/companyAdds the core system and company history starter data.
Day-based starternpx shadcn@latest add evilrabbit/lifeline/journeyAdds the core system and a bounded day-by-day data template.
Core component systemnpx shadcn@latest add evilrabbit/lifeline/lifelineAdds the timeline components and data helper with no page shell.
Theme switchernpx shadcn@latest add evilrabbit/lifeline/theme-switcherAdds the demo’s next-themes sun and moon control.

A hosted registry URL also works:

npx shadcn@latest add https://evilrabbit.com/r/lifeline.json

Basic Usage

Create timeline data with defineLifeline(), then pass the generated markers and starting year to <Lifeline />.

import { Lifeline, LifelineLegend } from "@/components/lifeline"
import {
  LifelineFooter,
  LifelineNav,
  LifelineShell,
  LifelineStage,
} from "@/components/lifeline-shell"
import { defineLifeline } from "@/lib/lifeline-data"
const companyHistory = defineLifeline({
  slug: "acme-history",
  name: "Acme Product History",
  birthYear: 2019,
  endYear: 2026,
  description: "Major releases and company milestones.",
  milestones: {
    2019: {
      id: "founded",
      events: ["Acme opened its first product studio."],
    },
    2021: {
      id: "public-launch",
      events: [
        {
          text: "Version 1.0 launched for public accounts.",
          image: {
            src: "/timeline/version-one.jpg",
            alt: "Acme version 1.0 dashboard",
          },
        },
      ],
    },
    2024: {
      id: "mobile-release",
      events: ["The mobile application entered general availability."],
      photos: [
        {
          src: "/timeline/mobile-app.jpg",
          alt: "Acme mobile application",
          width: 200,
        },
      ],
    },
    2026: {
      id: "platform-update",
      events: ["The platform added team workspaces and shared reports."],
    },
  },
})
export default function HistoryPage() {
  return (
    <LifelineShell>
      <LifelineNav
        logo={<span className="text-sm font-semibold">ACME</span>}
        logoLabel="Acme home"
      />
      <LifelineStage>
        <Lifeline
          markers={companyHistory.markers}
          birthYear={companyHistory.birthYear}
          title={companyHistory.name}
          mode="page"
          className="h-full"
        />
      </LifelineStage>
      <LifelineFooter>
        <LifelineLegend />
      </LifelineFooter>
    </LifelineShell>
  )
}

Timeline Data Structure

defineLifeline() accepts a record with slug, name, birthYear, description, optional endYear, optional legend labels, and milestones keyed by a numeric axis value. It creates one marker for every value from birthYear through endYear.

Record FieldTypePurpose
slugstringIdentifies the timeline record.
namestringSupplies the timeline name and common title value.
birthYearnumberSets the first axis value and the starting point for computed age labels.
endYearnumberSets the final axis value. The helper uses its current-year constant when omitted.
descriptionstringStores a summary with the timeline record.
legendLifelineLegendItem[]Renames the two people categories represented by mentor and met.
milestonesRecord<number, LifelineMilestone>Maps each year or sequential unit to its event data.

Milestone Fields

FieldAccepted DataBehavior
idstringSupplies the marker key.
eventsStrings, linked text segments, or event objectsRenders the main event content for the marker.
labelstringReplaces the raw numeric axis label with text such as Jun 16.
agenumber | stringOverrides the computed age or secondary axis label.
photosLifelinePhoto[]Adds draggable media cards that open in a lightbox.
badges{ src, alt }[]Renders small emblems above the event list.
companies{ id, name }[]Renders registered organization icons or an initial fallback.
mentors{ name, role?, color?, photo? }[]Adds people to the first legend row.
met{ name, photo? }[]Adds people to the second legend row.

The marker, event, media, company, and people shapes come from the installed TypeScript definitions.

An event object accepts text, optional image, and an optional effect:

{
  text: "The public beta opened.",
  image: {
    src: "/timeline/beta.jpg",
    alt: "Public beta interface",
    video: "/timeline/beta-preview.webm",
  },
  effect: "fireworks",
}

Use linked segments when part of an event needs a destination URL:

events: [
  [
    { type: "text", value: "Read the " },
    {
      type: "link",
      value: "release announcement",
      href: "/blog/version-two",
    },
    { type: "text", value: "." },
  ],
]

Event effects currently accept "fireworks" and "fireworks-argentina".

Position Photo Cards

A photo accepts optional placement values:

photos: [
  {
    src: "/timeline/team-retreat.jpg",
    alt: "Product team retreat",
    x: 0.6,
    y: 48,
    rotate: -3,
    width: 220,
  },
]

x runs from 0 to 1 across the marker’s slot. y uses pixels below the rail, rotate uses degrees, and width uses pixels. The component calculates a scattered position when these values are absent.

Register Company Icons

Register icon components once at module scope. The company id in milestone data must match the registry key.

import { registerCompanyIcons } from "@/components/lifeline/company-icon"
import { AcmeLogo, NorthstarLogo } from "@/components/brand-icons"
registerCompanyIcons({
  acme: {
    icon: AcmeLogo,
    sizeClassName: "h-4 w-5",
  },
  northstar: {
    icon: NorthstarLogo,
    sizeClassName: "h-4 w-8",
  },
})

Unknown company IDs render the first character of the company name inside a small ring.

Lifeline Props

<Lifeline
  markers={companyHistory.markers}
  birthYear={companyHistory.birthYear}
  title={companyHistory.name}
  mode="page"
  className="h-full"
/>
PropTypeDefaultPurpose
markersLifelineMarker[]RequiredSupplies the complete sequence of timeline markers.
birthYearnumberRequiredSets the axis origin and computed age row.
titlestring"Lifeline"Sets the accessible label on the timeline region.
mode"auto" | "page" | "embed""auto"Selects full-page behavior, embedded behavior, or runtime measurement.
classNamestringNoneExtends the desktop root and the embedded mobile scroll box.

Full-Page and Embedded Modes

Use mode="page" when the timeline owns the page viewport and its scroll input. LifelineShell creates an h-dvh column with clipped overflow, while LifelineStage supplies the scrolling context required by the responsive layouts.

Use mode="embed" for a timeline placed between other sections. Assign a fixed or responsive height to the wrapper:

<section className="h-[720px]">
  <Lifeline
    markers={companyHistory.markers}
    birthYear={companyHistory.birthYear}
    title={companyHistory.name}
    mode="embed"
    className="h-full"
  />
</section>

Desktop wheel input moves the rail while the pointer sits over the module. Scroll input returns to the page at the start and end of the timeline. On mobile, embed mode creates a bounded vertical scroll box that chains back to the page at both boundaries.

The wrapper height is mandatory. A missing height collapses the embedded module. Timelines with portrait rows and floating media usually need about 700px to 800px of vertical space.

mode="auto" measures the timeline at runtime. It selects page behavior when the component occupies at least half of the viewport and no outer vertical scroll remains available. Set the mode explicitly when the surrounding layout is already known.

Shell Components

ComponentMain PropsRole
LifelineShellchildren, classNameCreates the viewport-height page frame.
LifelineNavlogo, logoHref, logoLabel, children, className, containerClassNameRenders the fixed navigation and adds the rail alignment markers.
LifelineStagechildren, classNameClears the fixed navigation and hosts the responsive scroll area.
LifelineFooterchildren, className, containerClassNameCreates the footer region used for LifelineLegend.

LifelineNav requires a logo node. Its logoHref defaults to /, and logoLabel defaults to "Home".

Apply the same containerClassName to the navigation and footer. The rail endpoint follows the marked navigation container, while the footer uses its own container class.

Align the Rail with Existing Site Navigation

The supplied shell marks its navigation with two data attributes:

AttributeMeasurement
data-site-nav-logoSets the left edge of the first marker.
data-site-nav-innerSets the right boundary, minus 24 pixels of internal spacing.

Add the same attributes to an existing site header when the project already has navigation:

<header>
  <div
    data-site-nav-inner
    className="mx-auto flex h-16 max-w-6xl items-center px-6"
  >
    <a data-site-nav-logo href="/" aria-label="Home">
      <BrandLogo />
    </a>
    <SiteNavigation />
  </div>
</header>

The timeline remeasures these elements after resize. A timeline in a narrow card uses its own container when the marked header sits outside the module’s horizontal bounds.

When neither attribute exists, the rail starts 24 pixels inside the stage and ends 24 pixels before its right edge.

Generated Files and Customization

The core registry item writes the component system to:

components/lifeline/
├── company-icon.tsx
├── index.ts
├── lifeline-desktop.tsx
├── lifeline-event.tsx
├── lifeline-fireworks.tsx
├── lifeline-hover-image.tsx
├── lifeline-intro-timing.ts
├── lifeline-labels.tsx
├── lifeline-layout.ts
├── lifeline-legend.tsx
├── lifeline-lightbox.tsx
├── lifeline-marker.tsx
├── lifeline-people.tsx
├── lifeline-photos.tsx
├── lifeline-utils.ts
├── lifeline-vertical.tsx
├── lifeline.tsx
├── types.ts
├── use-lifeline-intro.ts
├── use-lifeline-scroll.ts
└── use-lifeline-vertical-scroll.ts
lib/
└── lifeline-data.ts

The registry also adds the intro keyframes and .lifeline-typeset rules to the project’s CSS. Shell and page registry items add components/lifeline-shell.tsx, starter data, and the optional app/lifeline/page.tsx route.

Change the Typography

Lifeline uses the --lifeline-font custom property. The default stack prefers Geist and falls back to a system sans-serif font.

Load the installed Geist font in the root layout:

import { GeistSans } from "geist/font/sans"
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en" className={GeistSans.variable}>
      <body>{children}</body>
    </html>
  )
}

Set another font directly in the global stylesheet when the timeline must match an existing design system:

.lifeline-typeset {
  --lifeline-font: var(--font-brand-sans), ui-sans-serif, system-ui, sans-serif;
}

The registry CSS and component source define the class and custom property.

Change Motion and Layout

The installed source exposes the main tuning values near the top of the relevant files:

  • lifeline-intro-timing.ts stores rail duration, fade timing, and scaling constants.
  • lifeline-layout.ts stores breakpoint and width calculations.
  • use-lifeline-scroll.ts stores wheel speed, drag speed, momentum, fade zones, and embedded boundary timing.
  • lifeline-hover-image.tsx stores image offset, tilt, and cursor-following values.
  • lifeline-photos.tsx stores card width, overlap, tilt, and stacking values.

Tailwind utility classes control the palette, spacing, borders, shadows, and dark mode. The shipped styles use the zinc palette.

Implementation Notes

Keep the Numeric Axis Current

lib/lifeline-data.ts currently defines LIFELINE_CURRENT_YEAR as 2026. defineLifeline() uses that constant when endYear is absent. Set endYear explicitly for archived timelines, or update the constant when a living timeline must extend into a new year.

Check Image Paths

Local images belong under public when milestone data uses paths such as /timeline/launch.jpg. Add precise images.remotePatterns entries in next.config.ts when installed components render remote media through next/image.

Preserve the Client Boundary

The installed Lifeline component reads window.matchMedia, measures DOM geometry, observes element visibility, and responds to pointer and wheel input. Keep its "use client" directive in place.

Give Mobile Page Mode a Scroll Parent

The vertical layout measures the nearest ancestor whose overflow-y value is auto or scroll. LifelineStage already supplies that structure. A custom page shell needs a bounded ancestor such as min-h-0 flex-1 overflow-y-auto.

Review Existing Files Before Installation

Use the shadcn CLI preview commands when the target project already contains similarly named files:

npx shadcn@latest add evilrabbit/lifeline/page --dry-run
npx shadcn@latest add evilrabbit/lifeline/page --diff

The current add command supports both --dry-run and --diff.

Alternatives and Related Resources

FAQs

Q: Why does an embedded Lifeline collapse?
A: mode="embed" does not calculate its own height. Add a fixed, viewport-based, or container-derived height to the wrapper and apply h-full to the component.

Q: Why does the desktop rail run from edge to edge?
A: The component did not find data-site-nav-logo and data-site-nav-inner. Add those attributes to the site header or place the timeline inside a capped container.

Q: Why does the timeline stop at 2026?
A: The installed data helper uses LIFELINE_CURRENT_YEAR = 2026 when endYear is missing. Update the constant or pass an explicit endYear.

Q: Can the App Router page remain a Server Component?
A: Yes. The installed Lifeline file defines the client boundary. Keep page-level data and static layout in the Server Component unless the page itself needs hooks or browser APIs.

Q: How do I disable the intro for reduced-motion users?
A: No extra configuration is required. The intro hook checks prefers-reduced-motion: reduce and opens the timeline in its settled state.

Evil Rabbit

Evil Rabbit

Founding Designer

Leave a Reply

Your email address will not be published. Required fields are marked *