f-ui
Components

Timeline

Vertical chronological rail for audit feeds, activity history, and milestone progress — ReUI-shaped compound parts.

Timeline renders time-ordered events on a vertical or horizontal rail. Defaults match ReUI Timeline (size-4 dots). Object Page activity logs use TimelineOpposite: the timestamp is a right-aligned column left of the rail (Ant title / MUI Opposite). mode is start, alternate, or end. Hosts own the event list and sort order.

Timeline vs Comment Thread

Use Timeline for read-only audit on a Detail tab (newest first, icon + title + summary). Use Comment Thread when operators reply with rich text and Shared / Internal visibility. See Approval And Case Patterns.

When To Use

  • The reader needs a visual sequence of events (what happened, in order) — not a sortable grid. Prefer a table when they must filter, select, or compare columns.
  • The feed is read-only. Operators who reply (Shared / Internal, rich text) belong on Comment Thread.
  • Do not use Timeline for a form wizard — use Stepper / Steps Form. Do not use it for streaming agent chat — use Message Scroller.

Pick the job first, then the layout. Examples below are the matching demos.

JobWhere it livesHow to composeExample
Activity log (audit of a request)Object Page Activity tab — one Section Card around the whole trackVertical; newest first; value={events.length}; TimelineOpposite for the timestamp; title = action; actor in meta; timelineIconActivity*Activity Log
People activitySame tab, when the actor matters more than the event typeAvatar in the indicator; same Opposite timestamp columnPeople Activity With Avatars
Order / shipment statusDetail body or a status cardVertical icons; value = current stop; trailing nodes stay pendingOrder Status
Approval / CI pipelineIn-card progress (not a wizard)Partial value; unfinished nodes stay openPipeline Steps
Deploy / job logOps panel; expand one step for raw outputCollapsible inside TimelineContentCollapsible Job Log
Roadmap / fundraising milestonesMarketing or planning pagemode="alternate" (centered zig-zag) or compact verticalAlternate Milestones, Compact Roadmap
Release / week stripWide header or overvieworientation="horizontal"; dates above the rail use timelineHorizontalLeading*Horizontal Milestones, Horizontal Leading Labels

Do not combine TimelineOpposite with mode="alternate". Do not wrap each event in its own card — the rail is the grouping.

Activity Log

This is the Object Page audit of a request (purchase request, invoice, case): who submitted, who returned, who approved — append-only, no composer.

Time sits in TimelineOpposite, not under the body. A rail already has a column across from the title — that column is for when. Operators scan 03-12 16:40 / 03-12 10:05 as a right-aligned stack (Ant title, MUI Opposite). The action stays the only strong line on the content side. Do not copy Salesforce’s trailing 9:00am | 3/20/17 onto a rail component: that pattern is for an icon+card list, not a timeline.

It is not the header Stepper (where the request sits in the flow) and not shipment stops (see Order Status). A returned request still belongs here as a completed Returned event, with the reason in the body.

  1. ShellSectionCard title="Activity" (or the tab body). One card for the whole track. Loading / Empty / Error stay inside that card (Page And Region Status).
  2. Order — newest first. Set value={events.length} so every node is completed history. A partial value is a pipeline, not an activity log.
  3. Node — title is the action (Submitted, Returned, Resubmitted, Approved). Actor + seat in TimelineMeta. Return / reject copy lives in the body — not a composer on this rail.
  4. Time — persist an ISO datetime. Render one string in TimelineOpposite, same tiers as Comment Thread: <24h2 hours ago; older → MM-DD HH:mm; titleYYYY-MM-DD HH:mm. Do not also put TimelineDate under the body. Seconds stay out of the UI.
  5. Dialogue — if reviewers reply on the same page, that is Comment Thread (Review / Returned). A read-only “comment recorded” line may appear on this rail as an audit snapshot; the composer does not.
  6. Scale — ≤ ~50 events: render the list. Longer: host-owned cursor pages — see Large Activity Feeds.

Approved

Omar Diaz · Finance
Approved PR-1042. Spend matches cost center CC-4802.

Resubmitted

Alex Chen · Requester
Updated the cost center to CC-4802 and resubmitted.

Returned

Priya Nair · Manager
Return: cost center does not match the cost object.

Submitted

Alex Chen · Requester
Opened purchase request PR-1042.
"use client";

/**
 * Canonical Object Page activity log: timestamp in TimelineOpposite
 * (Ant title column), action + actor on the content side, icon per event.
 */
import { CheckIcon, RotateCcwIcon, SendIcon } from "lucide-react";

import {
  formatCommentAbsoluteTime,
  formatCommentRelativeTime,
} from "@/components/f-ui/comment/comment-relative-time";
import {
  Timeline,
  TimelineContent,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineMeta,
  TimelineOpposite,
  TimelineSeparator,
  TimelineTitle,
  timelineIconActivityIndicatorClassName,
  timelineIconActivityItemClassName,
  timelineIconActivitySeparatorClassName,
} from "@/components/f-ui/timeline/timeline";

import {
  TIMELINE_ACTIVITY_LOG_EVENTS,
  TIMELINE_ACTIVITY_LOG_NOW,
} from "./timeline-activity-log-events";

const ICONS = {
  Approved: <CheckIcon className="size-3.5" />,
  Resubmitted: <SendIcon className="size-3.5" />,
  Returned: <RotateCcwIcon className="size-3.5" />,
  Submitted: <SendIcon className="size-3.5" />,
} as const;

export function TimelineDemo() {
  return (
    <Timeline
      value={TIMELINE_ACTIVITY_LOG_EVENTS.length}
      className="w-full max-w-md"
    >
      {TIMELINE_ACTIVITY_LOG_EVENTS.map((item) => (
        <TimelineItem
          key={item.id}
          step={item.id}
          className={timelineIconActivityItemClassName}
        >
          <TimelineHeader>
            <TimelineSeparator
              className={timelineIconActivitySeparatorClassName}
            />
            <TimelineOpposite
              dateTime={item.at}
              title={formatCommentAbsoluteTime(item.at)}
            >
              {formatCommentRelativeTime(item.at, TIMELINE_ACTIVITY_LOG_NOW)}
            </TimelineOpposite>
            <TimelineTitle className="mt-0.5">{item.title}</TimelineTitle>
            <TimelineIndicator
              className={timelineIconActivityIndicatorClassName}
            >
              {ICONS[item.title]}
            </TimelineIndicator>
          </TimelineHeader>
          <TimelineContent>
            <TimelineMeta>{item.meta}</TimelineMeta>
            {item.body}
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Object Page seating (Activity tab vs Review dialogue) is in Approval And Case Patterns.

Features

AreaBehavior
PrimitiveReUI defaults: size-4 bordered dots, ms-8 vertical gutter
Opposite timeTimelineOpposite — activity-log timestamp column (Ant title); whole string; gutter ms-40; height matches the node (h-4 / h-6) so the time does not sit above the icon
Modestart (default) · alternate (zig-zag) · end (rail on the right)
Icon activityApply timelineIconActivityItemClassName, …Separator…, …Indicator…
Horizontal leadingDates above the rail: timelineHorizontalLeading* helpers
StepsTimelineItem step + root valuedata-completed
DataHost maps any event array

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/timeline.json
npx shadcn@latest add https://ui.isaacfei.com/r/timeline.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/timeline.json
bun x shadcn@latest add https://ui.isaacfei.com/r/timeline.json

With a namespace: npx shadcn@latest add @f-ui/timeline.

registryDependencies: utils. Runtime: radix-ui.

Usage

import {
  Timeline,
  TimelineContent,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineMeta,
  TimelineOpposite,
  TimelineSeparator,
  TimelineTitle,
  timelineIconActivityIndicatorClassName,
  timelineIconActivityItemClassName,
  timelineIconActivitySeparatorClassName,
} from "@/components/f-ui/timeline/timeline";

<Timeline value={events.length} className="w-full max-w-md">
  {events.map((event) => (
    <TimelineItem
      key={event.id}
      step={event.id}
      className={timelineIconActivityItemClassName}
    >
      <TimelineHeader>
        <TimelineSeparator className={timelineIconActivitySeparatorClassName} />
        <TimelineOpposite dateTime={event.at} title={event.absolute}>
          {event.when}
        </TimelineOpposite>
        <TimelineTitle className="mt-0.5">{event.title}</TimelineTitle>
        <TimelineIndicator className={timelineIconActivityIndicatorClassName}>
          {event.icon}
        </TimelineIndicator>
      </TimelineHeader>
      <TimelineContent>
        <TimelineMeta>{event.meta}</TimelineMeta>
        {event.description}
      </TimelineContent>
    </TimelineItem>
  ))}
</Timeline>

Examples

People Activity With Avatars

Same request when the actor is the primary scan: Avatar in the indicator. Timestamp stays in TimelineOpposite.

Approved

Omar Diaz · Finance
Approved PR-1042. Spend matches cost center CC-4802.

Returned

Priya Nair · Manager
Return: cost center does not match the cost object.

Submitted

Alex Chen · Requester
Opened purchase request PR-1042.
"use client";

import { Avatar, AvatarFallback } from "@/components/ui/avatar";

import {
  formatCommentAbsoluteTime,
  formatCommentRelativeTime,
} from "@/components/f-ui/comment/comment-relative-time";
import {
  Timeline,
  TimelineContent,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineMeta,
  TimelineOpposite,
  TimelineSeparator,
  TimelineTitle,
  timelineIconActivityIndicatorClassName,
  timelineIconActivityItemClassName,
  timelineIconActivitySeparatorClassName,
} from "@/components/f-ui/timeline/timeline";

import {
  TIMELINE_ACTIVITY_LOG_EVENTS,
  TIMELINE_ACTIVITY_LOG_NOW,
} from "./timeline-activity-log-events";

const PEOPLE_EVENTS = TIMELINE_ACTIVITY_LOG_EVENTS.filter(
  (item) => item.title !== "Resubmitted",
);

export function TimelineAvatarDemo() {
  return (
    <Timeline value={PEOPLE_EVENTS.length} className="w-full max-w-md">
      {PEOPLE_EVENTS.map((item) => (
        <TimelineItem
          key={item.id}
          step={item.id}
          className={timelineIconActivityItemClassName}
        >
          <TimelineHeader>
            <TimelineSeparator className={timelineIconActivitySeparatorClassName} />
            <TimelineOpposite
              dateTime={item.at}
              title={formatCommentAbsoluteTime(item.at)}
            >
              {formatCommentRelativeTime(item.at, TIMELINE_ACTIVITY_LOG_NOW)}
            </TimelineOpposite>
            <TimelineTitle className="mt-0.5">{item.title}</TimelineTitle>
            <TimelineIndicator
              className={timelineIconActivityIndicatorClassName}
            >
              <Avatar size="sm" className="size-6">
                <AvatarFallback className="text-[10px]">
                  {item.initials}
                </AvatarFallback>
              </Avatar>
            </TimelineIndicator>
          </TimelineHeader>
          <TimelineContent>
            <TimelineMeta>{item.meta}</TimelineMeta>
            {item.body}
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Basic

Date stacked above the title on the content side of the rail (default ReUI header order). Use this when time is secondary to the title — not the Object Page audit.

Project started

Repository and initial architecture were set up.

Beta release

Early testers received the first build.

Launch

The product went live for all users.
"use client";

/**
 * Docs demo — ReUI basic (c-timeline-1): date stacked above the title on the content side.
 */
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
} from "@/components/f-ui/timeline/timeline";

const EVENTS = [
  {
    id: 1,
    date: "March 2024",
    title: "Project started",
    description: "Repository and initial architecture were set up.",
  },
  {
    id: 2,
    date: "April 2024",
    title: "Beta release",
    description: "Early testers received the first build.",
  },
  {
    id: 3,
    date: "June 2024",
    title: "Launch",
    description: "The product went live for all users.",
  },
] as const;

export function TimelineBasicDemo() {
  return (
    <Timeline defaultValue={2} className="w-full max-w-md">
      {EVENTS.map((item) => (
        <TimelineItem key={item.id} step={item.id}>
          <TimelineHeader>
            <TimelineSeparator />
            <TimelineDate>{item.date}</TimelineDate>
            <TimelineTitle>{item.title}</TimelineTitle>
            <TimelineIndicator />
          </TimelineHeader>
          <TimelineContent>{item.description}</TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Opposite Timestamps

Same time column as the activity log, default dots instead of event icons. Ant title / MUI Opposite: the whole timestamp is one string left of the rail.

Approved

Omar Diaz · Finance
Approved PR-1042. Spend matches cost center CC-4802.

Resubmitted

Alex Chen · Requester
Updated the cost center to CC-4802 and resubmitted.

Returned

Priya Nair · Manager
Return: cost center does not match the cost object.

Submitted

Alex Chen · Requester
Opened purchase request PR-1042.
"use client";

/**
 * Docs demo — Ant `title` / MUI Opposite with default dots.
 * Canonical activity log (icons + Opposite) is TimelineDemo.
 */
import {
  formatCommentAbsoluteTime,
  formatCommentRelativeTime,
} from "@/components/f-ui/comment/comment-relative-time";
import {
  Timeline,
  TimelineContent,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineMeta,
  TimelineOpposite,
  TimelineSeparator,
  TimelineTitle,
} from "@/components/f-ui/timeline/timeline";

import {
  TIMELINE_ACTIVITY_LOG_EVENTS,
  TIMELINE_ACTIVITY_LOG_NOW,
} from "./timeline-activity-log-events";

export function TimelineLeadingLabelsDemo() {
  return (
    <Timeline
      value={TIMELINE_ACTIVITY_LOG_EVENTS.length}
      className="w-full max-w-md"
    >
      {TIMELINE_ACTIVITY_LOG_EVENTS.map((item) => (
        <TimelineItem key={item.id} step={item.id}>
          <TimelineHeader>
            <TimelineSeparator />
            <TimelineOpposite
              dateTime={item.at}
              title={formatCommentAbsoluteTime(item.at)}
            >
              {formatCommentRelativeTime(item.at, TIMELINE_ACTIVITY_LOG_NOW)}
            </TimelineOpposite>
            <TimelineTitle className="sm:-mt-0.5">{item.title}</TimelineTitle>
            <TimelineIndicator />
          </TimelineHeader>
          <TimelineContent>
            <TimelineMeta>{item.meta}</TimelineMeta>
            {item.body}
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Order Status

Shipment stages completed through the current stop (value={3} of four). Past and current use success / info icons; the trailing Delivered node stays muted and pending.

Packed

Warehouse sealed the carton and printed the shipping label.

In transit

Carrier scanned the package at the origin hub.

Out for delivery

Courier has the package on today's route.

Delivered

Awaiting proof of delivery scan.
"use client";

/**
 * Docs demo — shipment stages (TL11): completed through current; last node pending.
 */
import { CheckCircle2, MapPin, Package, Truck } from "lucide-react";

import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
  timelineIconActivityItemClassName,
  timelineIconActivitySeparatorClassName,
} from "@/components/f-ui/timeline/timeline";

const STAGES = [
  {
    id: 1,
    title: "Packed",
    description: "Warehouse sealed the carton and printed the shipping label.",
    date: "Mon 9:12",
    icon: Package,
    iconClassName: "text-success",
  },
  {
    id: 2,
    title: "In transit",
    description: "Carrier scanned the package at the origin hub.",
    date: "Mon 14:40",
    icon: Truck,
    iconClassName: "text-success",
  },
  {
    id: 3,
    title: "Out for delivery",
    description: "Courier has the package on today's route.",
    date: "Tue 8:05",
    icon: MapPin,
    iconClassName: "text-info",
  },
  {
    id: 4,
    title: "Delivered",
    description: "Awaiting proof of delivery scan.",
    date: "Est. Tue 18:00",
    icon: CheckCircle2,
    iconClassName: "text-muted-foreground",
  },
] as const;

export function TimelineOrderStatusDemo() {
  return (
    <Timeline value={3} className="w-full max-w-md">
      {STAGES.map((item) => {
        const Icon = item.icon;
        return (
          <TimelineItem
            key={item.id}
            step={item.id}
            className={timelineIconActivityItemClassName}
          >
            <TimelineHeader>
              <TimelineSeparator
                className={timelineIconActivitySeparatorClassName}
              />
              <TimelineTitle className="mt-0.5">{item.title}</TimelineTitle>
              <TimelineIndicator className="bg-background flex size-6 items-center justify-center border-none group-data-[orientation=vertical]/timeline:-left-7">
                <Icon className={`size-3.5 ${item.iconClassName}`} />
              </TimelineIndicator>
            </TimelineHeader>
            <TimelineContent>
              {item.description}
              <TimelineDate className="mt-2 mb-0">{item.date}</TimelineDate>
            </TimelineContent>
          </TimelineItem>
        );
      })}
    </Timeline>
  );
}

Pipeline Steps

Where the request sits in the flow (manager done, finance still open) — not the Activity tab. Partial value so trailing steps stay incomplete. Unfinished nodes remain open (default size-4 dots). Contrast with Activity Log, where every past action is completed.

Submitted

Purchase request opened by the requester.

Manager review

Cost-center manager is reviewing spend and vendor.

Finance review

Budget and payment terms still pending.

Scheduled

Fulfillment date set after finance clears.
"use client";

/**
 * Docs demo — approval pipeline (TL11): active step with incomplete trailing nodes.
 * Pending dates: empty → EmptyValuePlaceholder (read-only empty), not fake "—" data.
 */
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
} from "@/components/f-ui/timeline/timeline";
import { EmptyValuePlaceholder } from "@/components/f-ui/empty-value-placeholder";

const STEPS = [
  {
    id: 1,
    title: "Submitted",
    description: "Purchase request opened by the requester.",
    date: "Mar 10",
  },
  {
    id: 2,
    title: "Manager review",
    description: "Cost-center manager is reviewing spend and vendor.",
    date: "Mar 11",
  },
  {
    id: 3,
    title: "Finance review",
    description: "Budget and payment terms still pending.",
    date: "",
  },
  {
    id: 4,
    title: "Scheduled",
    description: "Fulfillment date set after finance clears.",
    date: "",
  },
] as const;

export function TimelinePipelineDemo() {
  return (
    <Timeline value={2} className="w-full max-w-md">
      {STEPS.map((item) => (
        <TimelineItem key={item.id} step={item.id}>
          <TimelineHeader>
            <TimelineSeparator />
            <TimelineTitle className="mt-0.5">{item.title}</TimelineTitle>
            <TimelineIndicator />
          </TimelineHeader>
          <TimelineContent>
            {item.description}
            <TimelineDate className="mt-2 mb-0">
              {item.date ? item.date : <EmptyValuePlaceholder />}
            </TimelineDate>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Alternate Milestones

mode="alternate" zig-zags items around a centered rail. Do not also use TimelineOpposite.

Seed funding

Product MVP

First customer

Series A

Regional expansion

"use client";

/**
 * Docs demo — Ant / ReUI alternate: events zig-zag around a centered rail.
 */
import {
  Timeline,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
} from "@/components/f-ui/timeline/timeline";

const MILESTONES = [
  { id: 1, date: "Jan 2024", title: "Seed funding" },
  { id: 2, date: "Mar 2024", title: "Product MVP" },
  { id: 3, date: "May 2024", title: "First customer" },
  { id: 4, date: "Jul 2024", title: "Series A" },
  { id: 5, date: "Sep 2024", title: "Regional expansion" },
] as const;

export function TimelineAlternateDemo() {
  return (
    <Timeline defaultValue={3} mode="alternate" className="w-full max-w-md">
      {MILESTONES.map((item) => (
        <TimelineItem key={item.id} step={item.id}>
          <TimelineHeader>
            <TimelineSeparator />
            <TimelineDate>{item.date}</TimelineDate>
            <TimelineTitle>{item.title}</TimelineTitle>
            <TimelineIndicator />
          </TimelineHeader>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

End Aligned

mode="end" puts the rail on the right and content on the left.

Order placed

The order was received and queued for payment.

Paid

Payment cleared. Warehouse started picking.

Shipped

Carrier scanned the carton at origin.
"use client";

/**
 * Docs demo — Ant mode="end": rail on the right, content on the left.
 */
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
} from "@/components/f-ui/timeline/timeline";

const EVENTS = [
  {
    id: 1,
    date: "Mar 15",
    title: "Order placed",
    description: "The order was received and queued for payment.",
  },
  {
    id: 2,
    date: "Mar 16",
    title: "Paid",
    description: "Payment cleared. Warehouse started picking.",
  },
  {
    id: 3,
    date: "Mar 18",
    title: "Shipped",
    description: "Carrier scanned the carton at origin.",
  },
] as const;

export function TimelineEndDemo() {
  return (
    <Timeline defaultValue={3} mode="end" className="w-full max-w-md">
      {EVENTS.map((item) => (
        <TimelineItem key={item.id} step={item.id}>
          <TimelineHeader>
            <TimelineSeparator />
            <TimelineDate>{item.date}</TimelineDate>
            <TimelineTitle>{item.title}</TimelineTitle>
            <TimelineIndicator />
          </TimelineHeader>
          <TimelineContent>{item.description}</TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Compact Roadmap

Dense vertical list: small filled nodes and a date on the header row. Semantic node colors — not decorative hex.

Beta program completed
Usability testing completed
Design phase started
Requirements gathered
"use client";

/**
 * Docs demo — ReUI compact roadmap density; semantic node colors, no ALL CAPS.
 */
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
} from "@/components/f-ui/timeline/timeline";
import { cn } from "@/lib/utils";

const ITEMS = [
  {
    date: "Dec 15, 2025",
    content: "Beta program completed",
    color: "bg-success",
  },
  {
    date: "Nov 1, 2025",
    content: "Usability testing completed",
    color: "bg-info",
  },
  {
    date: "Oct 15, 2025",
    content: "Design phase started",
    color: "bg-warning",
  },
  {
    date: "Aug 1, 2024",
    content: "Requirements gathered",
    color: "bg-muted-foreground",
  },
] as const;

export function TimelineCompactRoadmapDemo() {
  return (
    <div className="w-full max-w-xs">
      <Timeline defaultValue={0} className="gap-2.5">
        {ITEMS.map((item, index) => (
          <TimelineItem
            key={item.date}
            step={index + 1}
            className="group-data-[orientation=vertical]/timeline:not-last:pb-0"
          >
            <TimelineHeader className="flex items-center gap-2.5">
              <TimelineSeparator />
              <TimelineIndicator
                className={cn("size-2 border-none", item.color)}
              />
              <TimelineDate className="text-muted-foreground/80 mb-0 text-[10px] font-semibold">
                {item.date}
              </TimelineDate>
            </TimelineHeader>
            <TimelineContent className="text-foreground text-sm font-medium">
              {item.content}
            </TimelineContent>
          </TimelineItem>
        ))}
      </Timeline>
    </div>
  );
}

Collapsible Job Log

Deploy/CI activity where one step expands a short monospace log via Collapsible inside TimelineContent — other steps stay plain.

Build finished

Production image built for commit a3f9c21.

Tests passed

Unit and integration suites completed with 0 failures.

Job started

CI runner claimed the deploy pipeline.
"use client";

/**
 * Docs demo — deploy/job log with one Collapsible monospace body.
 */
import { CheckIcon, ChevronRightIcon, PlayIcon } from "lucide-react";

import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
  timelineIconActivityIndicatorClassName,
  timelineIconActivityItemClassName,
  timelineIconActivitySeparatorClassName,
} from "@/components/f-ui/timeline/timeline";

const BUILD_LOG = `> pnpm build
✓ Compiled successfully in 14.2s
✓ Generated 48 static pages
Done in 16.1s`;

const EVENTS = [
  {
    id: 1,
    title: "Build finished",
    body: "Production image built for commit a3f9c21.",
    date: "2 minutes ago",
    icon: <CheckIcon className="size-3.5" />,
    log: BUILD_LOG,
  },
  {
    id: 2,
    title: "Tests passed",
    body: "Unit and integration suites completed with 0 failures.",
    date: "5 minutes ago",
    icon: <CheckIcon className="size-3.5" />,
  },
  {
    id: 3,
    title: "Job started",
    body: "CI runner claimed the deploy pipeline.",
    date: "8 minutes ago",
    icon: <PlayIcon className="size-3.5" />,
  },
] as const;

export function TimelineCollapsibleDemo() {
  return (
    <Timeline value={EVENTS.length} className="w-full max-w-md">
      {EVENTS.map((item) => (
        <TimelineItem
          key={item.id}
          step={item.id}
          className={timelineIconActivityItemClassName}
        >
          <TimelineHeader>
            <TimelineSeparator
              className={timelineIconActivitySeparatorClassName}
            />
            <TimelineTitle className="mt-0.5">{item.title}</TimelineTitle>
            <TimelineIndicator
              className={timelineIconActivityIndicatorClassName}
            >
              {item.icon}
            </TimelineIndicator>
          </TimelineHeader>
          <TimelineContent>
            {item.body}
            {"log" in item && item.log ? (
              <Collapsible className="group/collapsible mt-2">
                <CollapsibleTrigger className="text-foreground flex items-center gap-1 text-xs font-medium hover:underline">
                  View log
                  <ChevronRightIcon className="size-3.5 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
                </CollapsibleTrigger>
                <CollapsibleContent>
                  <pre className="text-muted-foreground mt-2 font-mono text-xs whitespace-pre-wrap">
                    {item.log}
                  </pre>
                </CollapsibleContent>
              </Collapsible>
            ) : null}
            <TimelineDate className="mt-2 mb-0">{item.date}</TimelineDate>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Horizontal Milestones

Short release strip — default size-4 dots, dates in the header.

Planning

Scope definition and resource planning.

Design

UI/UX design and prototyping.

Development

Core features implementation.
"use client";

/**
 * Docs demo — port of ReUI `c-timeline-8` (Horizontal Orientation).
 * @see https://github.com/keenthemes/reui/blob/main/registry-reui/bases/radix/components/timeline/c-timeline-8.tsx
 */
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
} from "@/components/f-ui/timeline/timeline";

const MILESTONES = [
  {
    id: 1,
    date: "Week 1",
    title: "Planning",
    description: "Scope definition and resource planning.",
  },
  {
    id: 2,
    date: "Week 2",
    title: "Design",
    description: "UI/UX design and prototyping.",
  },
  {
    id: 3,
    date: "Week 4",
    title: "Development",
    description: "Core features implementation.",
  },
] as const;

export function TimelineHorizontalDemo() {
  return (
    <Timeline
      value={2}
      orientation="horizontal"
      className="w-full max-w-xl"
    >
      {MILESTONES.map((item) => (
        <TimelineItem key={item.id} step={item.id}>
          <TimelineHeader>
            <TimelineSeparator />
            <TimelineDate>{item.date}</TimelineDate>
            <TimelineTitle>{item.title}</TimelineTitle>
            <TimelineIndicator />
          </TimelineHeader>
          <TimelineContent>{item.description}</TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Horizontal Leading Labels

Dates sit above the horizontal rail (timelineHorizontalLeading* helpers). This is not TimelineOpposite (that part is vertical).

Kickoff

Goals and core team were confirmed.

Discovery

User research and requirements.

Build

Core development sprints.
"use client";

/**
 * Docs demo — ReUI c-timeline-9: dates sit above a horizontal rail.
 */
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
  timelineHorizontalLeadingDateClassName,
  timelineHorizontalLeadingIndicatorClassName,
  timelineHorizontalLeadingItemClassName,
  timelineHorizontalLeadingSeparatorClassName,
} from "@/components/f-ui/timeline/timeline";

const PHASES = [
  {
    id: 1,
    date: "Oct 2024",
    title: "Kickoff",
    description: "Goals and core team were confirmed.",
  },
  {
    id: 2,
    date: "Nov 2024",
    title: "Discovery",
    description: "User research and requirements.",
  },
  {
    id: 3,
    date: "Dec 2024",
    title: "Build",
    description: "Core development sprints.",
  },
] as const;

export function TimelineHorizontalLeadingDemo() {
  return (
    <Timeline
      defaultValue={2}
      orientation="horizontal"
      className="w-full max-w-xl"
    >
      {PHASES.map((item) => (
        <TimelineItem
          key={item.id}
          step={item.id}
          className={timelineHorizontalLeadingItemClassName}
        >
          <TimelineHeader>
            <TimelineSeparator
              className={timelineHorizontalLeadingSeparatorClassName}
            />
            <TimelineDate className={timelineHorizontalLeadingDateClassName}>
              {item.date}
            </TimelineDate>
            <TimelineTitle>{item.title}</TimelineTitle>
            <TimelineIndicator
              className={timelineHorizontalLeadingIndicatorClassName}
            />
          </TimelineHeader>
          <TimelineContent>{item.description}</TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Compact Horizontal Releases

Tighter horizontal strip with version marks and a current-release hint.

v1.0

Initial release

v1.1

Bug fixes

v2.0Current

Major update

v2.1

Improvements
"use client";

/**
 * Docs demo — compact horizontal release strip (ReUI c-timeline-12 density).
 */
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
} from "@/components/f-ui/timeline/timeline";
import { cn } from "@/lib/utils";
import { CheckIcon, CircleIcon, CircleDotIcon } from "lucide-react";

const RELEASES = [
  {
    id: 1,
    version: "v1.0",
    date: "Jan 2025",
    title: "Initial release",
    status: "released" as const,
  },
  {
    id: 2,
    version: "v1.1",
    date: "Mar 2025",
    title: "Bug fixes",
    status: "released" as const,
  },
  {
    id: 3,
    version: "v2.0",
    date: "Jun 2025",
    title: "Major update",
    status: "current" as const,
  },
  {
    id: 4,
    version: "v2.1",
    date: "Sep 2025",
    title: "Improvements",
    status: "upcoming" as const,
  },
];

function ReleaseMark({ status }: { status: (typeof RELEASES)[number]["status"] }) {
  if (status === "released") return <CheckIcon className="size-3" />;
  if (status === "current") return <CircleDotIcon className="size-3" />;
  return <CircleIcon className="size-3" />;
}

export function TimelineCompactHorizontalDemo() {
  return (
    <Timeline
      defaultValue={3}
      orientation="horizontal"
      className="w-full max-w-xl"
    >
      {RELEASES.map((release) => (
        <TimelineItem key={release.id} step={release.id}>
          <TimelineHeader>
            <TimelineSeparator />
            <TimelineDate>{release.date}</TimelineDate>
            <TimelineTitle className="flex items-center gap-1.5 text-sm">
              {release.version}
              {release.status === "current" ? (
                <span className="text-info text-xs font-medium">Current</span>
              ) : null}
            </TimelineTitle>
            <TimelineIndicator
              className={cn(
                "flex size-5 items-center justify-center",
                release.status === "upcoming" && "text-muted-foreground",
              )}
            >
              <ReleaseMark status={release.status} />
            </TimelineIndicator>
          </TimelineHeader>
          <TimelineContent>{release.title}</TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Long Activity Feed

Scale-up of the activity log: host-owned cursor paging in a fixed-height scroll region. Loading skeleton, Empty, and Error + Retry, then Load more for later pages. Toggle the switches to exercise Empty / Error. Timeline stays presentational — the host owns fetch and list growth.

"use client";

/**
 * Host-owned long activity feed recipe (Approach B / TL8).
 *
 * TL8 choice: **cursor paging** inside a fixed-height overflow region —
 * mount all *loaded* pages only. Absolute-row virtualization was rejected
 * for this rail: Timeline separators use `group-last/timeline-item` and
 * flex-column `not-last` spacing, so unmounting mid-list neighbors breaks
 * the continuous connector. Prefer `useVirtualizer` + `measureElement` only
 * when rows are tall (e.g. collapsible logs) and you can keep the rail intact.
 */
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon, SendIcon } from "lucide-react";

import { Empty } from "@/components/f-ui/empty/empty";
import { Result } from "@/components/f-ui/result/result";
import {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
  timelineIconActivityIndicatorClassName,
  timelineIconActivityItemClassName,
  timelineIconActivitySeparatorClassName,
} from "@/components/f-ui/timeline/timeline";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";

import {
  fetchTimelineFeedPage,
  type TimelineFeedCursor,
  type TimelineFeedEvent,
} from "./timeline-infinite-mock";

const PAGE_SIZE = 20;
const LOAD_LATENCY_MS = 280;

type FeedStatus = "loading" | "empty" | "error" | "populated";

function eventIcon(id: number) {
  return id % 2 === 0 ? (
    <SendIcon className="size-3.5" />
  ) : (
    <CheckIcon className="size-3.5" />
  );
}

function TimelineFeedSkeleton() {
  return (
    <div
      role="status"
      aria-label="Loading activity"
      className="flex h-80 flex-col gap-4 p-1"
    >
      {Array.from({ length: 4 }, (_, i) => (
        <div key={i} className="flex gap-3">
          <Skeleton className="size-6 shrink-0 rounded-full" />
          <div className="flex flex-1 flex-col gap-2 pt-0.5">
            <Skeleton className="h-4 w-2/3" />
            <Skeleton className="h-3 w-full" />
            <Skeleton className="h-3 w-24" />
          </div>
        </div>
      ))}
    </div>
  );
}

export function TimelineInfiniteVirtualDemo() {
  const [events, setEvents] = useState<TimelineFeedEvent[]>([]);
  const [nextCursor, setNextCursor] = useState<TimelineFeedCursor>(null);
  const [status, setStatus] = useState<FeedStatus>("loading");
  const [fetchingMore, setFetchingMore] = useState(false);
  const [loadMoreFailed, setLoadMoreFailed] = useState(false);
  const [simulateError, setSimulateError] = useState(false);
  const [simulateEmpty, setSimulateEmpty] = useState(false);
  const simulateErrorRef = useRef(simulateError);
  const simulateEmptyRef = useRef(simulateEmpty);
  simulateErrorRef.current = simulateError;
  simulateEmptyRef.current = simulateEmpty;
  const requestIdRef = useRef(0);

  const loadInitial = useCallback(async () => {
    const requestId = ++requestIdRef.current;
    setStatus("loading");
    setEvents([]);
    setNextCursor(null);
    setFetchingMore(false);
    setLoadMoreFailed(false);
    try {
      const page = await fetchTimelineFeedPage(null, PAGE_SIZE, {
        latencyMs: LOAD_LATENCY_MS,
        shouldFail: simulateErrorRef.current,
        empty: simulateEmptyRef.current,
      });
      if (requestId !== requestIdRef.current) return;
      setEvents(page.events);
      setNextCursor(page.nextCursor);
      setStatus(page.events.length === 0 ? "empty" : "populated");
    } catch {
      if (requestId !== requestIdRef.current) return;
      setEvents([]);
      setNextCursor(null);
      setStatus("error");
    }
  }, []);

  useEffect(() => {
    void loadInitial();
  }, [loadInitial]);

  const loadMore = async () => {
    if (!nextCursor || fetchingMore) return;
    const requestId = ++requestIdRef.current;
    setFetchingMore(true);
    setLoadMoreFailed(false);
    try {
      const page = await fetchTimelineFeedPage(nextCursor, PAGE_SIZE, {
        latencyMs: LOAD_LATENCY_MS,
        shouldFail: simulateErrorRef.current,
      });
      if (requestId !== requestIdRef.current) return;
      setEvents((prev) => [...prev, ...page.events]);
      setNextCursor(page.nextCursor);
    } catch {
      if (requestId !== requestIdRef.current) return;
      // Keep loaded pages; only initial failure takes over with Result.
      setLoadMoreFailed(true);
    } finally {
      if (requestId === requestIdRef.current) {
        setFetchingMore(false);
      }
    }
  };

  const onSimulateChange = (kind: "error" | "empty", checked: boolean) => {
    if (kind === "error") {
      simulateErrorRef.current = checked;
      setSimulateError(checked);
      if (checked) {
        simulateEmptyRef.current = false;
        setSimulateEmpty(false);
      }
    } else {
      simulateEmptyRef.current = checked;
      setSimulateEmpty(checked);
      if (checked) {
        simulateErrorRef.current = false;
        setSimulateError(false);
      }
    }
    void loadInitial();
  };

  return (
    <div className="space-y-3">
      <div className="bg-card border-border flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border px-3 py-2">
        <div className="flex items-center gap-2.5">
          <Switch
            id="timeline-feed-simulate-error"
            size="sm"
            checked={simulateError}
            onCheckedChange={(checked) => onSimulateChange("error", checked)}
            aria-label="Simulate feed error"
          />
          <Label
            htmlFor="timeline-feed-simulate-error"
            className="text-sm font-normal"
          >
            Simulate error
          </Label>
        </div>
        <div className="flex items-center gap-2.5">
          <Switch
            id="timeline-feed-simulate-empty"
            size="sm"
            checked={simulateEmpty}
            onCheckedChange={(checked) => onSimulateChange("empty", checked)}
            aria-label="Simulate empty feed"
          />
          <Label
            htmlFor="timeline-feed-simulate-empty"
            className="text-sm font-normal"
          >
            Simulate empty
          </Label>
        </div>
      </div>

      <div className="rounded-xl border bg-card p-4 shadow-sm">
        {status === "loading" ? <TimelineFeedSkeleton /> : null}

        {status === "error" ? (
          <Result
            size="region"
            status="error"
            title="Could not load activity"
            subTitle="Check your connection and try again."
            extra={
              <Button type="button" variant="outline" onClick={() => void loadInitial()}>
                Retry
              </Button>
            }
          />
        ) : null}

        {status === "empty" ? (
          <Empty
            size="region"
            title="No activity yet"
            description="Events for this purchase request will show up here."
            action={
              <Button type="button" variant="outline" onClick={() => void loadInitial()}>
                Refresh
              </Button>
            }
          />
        ) : null}

        {status === "populated" ? (
          <div className="space-y-3">
            <div className="h-80 overflow-auto pr-1">
              <Timeline value={events.length} className="w-full max-w-md">
                {events.map((event, index) => (
                  <TimelineItem
                    key={event.id}
                    step={index + 1}
                    className={timelineIconActivityItemClassName}
                  >
                    <TimelineHeader>
                      <TimelineSeparator
                        className={timelineIconActivitySeparatorClassName}
                      />
                      <TimelineTitle className="mt-0.5">
                        {event.title}
                      </TimelineTitle>
                      <TimelineIndicator
                        className={timelineIconActivityIndicatorClassName}
                      >
                        {eventIcon(event.id)}
                      </TimelineIndicator>
                    </TimelineHeader>
                    <TimelineContent>
                      {event.body}
                      <TimelineDate className="mt-2 mb-0">
                        {event.date}
                      </TimelineDate>
                    </TimelineContent>
                  </TimelineItem>
                ))}
              </Timeline>
            </div>
            <div className="flex flex-wrap items-center gap-3">
              {nextCursor ? (
                <Button
                  type="button"
                  variant="outline"
                  disabled={fetchingMore}
                  onClick={() => void loadMore()}
                >
                  {fetchingMore ? "Loading…" : loadMoreFailed ? "Retry" : "Load more"}
                </Button>
              ) : (
                <p className="text-muted-foreground text-sm">End of activity</p>
              )}
              {fetchingMore ? (
                <span className="text-muted-foreground text-xs">
                  Fetching next page…
                </span>
              ) : null}
              {loadMoreFailed && !fetchingMore ? (
                <span className="text-destructive text-xs">
                  Could not load the next page
                </span>
              ) : null}
            </div>
          </div>
        ) : null}
      </div>
    </div>
  );
}

Large Activity Feeds

Use Timeline for the rail chrome only. The host owns pagination, async state, and (when needed) virtualization — there is no kit virtual / infinite / onLoadMore API.

Loaded / rendered sizePattern
≤ ~50 eventsFull Timeline render
~50–200Cursor / keyset pages + Load more (or sentinel); DOM is usually fine for audit rows
≥ ~200 or tall / variable rowsSame scroll parent: virtualize visible rows and keep paging the server

Async triad: every feed region that loads from the network must host-branch Loading / Empty / Error — see Page And Region Status.

Rail aesthetics: absolute-row virtualization can break Timeline’s continuous separator (group-last, flex not-last spacing). Prefer cursor paging that mounts all loaded pages first. Add @tanstack/react-virtual with measureElement + overscan only when rows are tall enough that DOM cost still hurts — and verify the rail still reads continuous.

Composition

Timeline (value, mode?, orientation?)
└── TimelineItem (step)
    ├── TimelineHeader
    │   ├── TimelineSeparator
    │   ├── TimelineOpposite    ← activity log timestamp
    │   ├── TimelineTitle
    │   └── TimelineIndicator
    └── TimelineContent
        ├── TimelineMeta?
        ├── description
        └── TimelineDate?       ← only when Opposite is not used

API Reference

Timeline

PropTypeDefaultDescription
valuenumberHighest completed step. Use items.length for full audit feeds.
defaultValuenumber1Initial step when uncontrolled.
onValueChange(value: number) => voidOptional.
orientation"vertical" | "horizontal""vertical"Rail direction.
mode"start" | "alternate" | "end""start"Content vs rail. alternate zig-zags; end puts the rail on the right. Do not combine with TimelineOpposite.
classNamestringe.g. w-full max-w-md.

TimelineItem

PropTypeDefaultDescription
stepnumber1-based; completed when step <= value.
classNamestringUse timelineIconActivityItemClassName for icon audit.

Icon activity helpers

ExportRole
timelineIconActivityItemClassNamems-10 gutter for size-6 nodes
timelineIconActivitySeparatorClassNameAlign connector to size-6
timelineIconActivityIndicatorClassNameFilled size-6 icon node

Horizontal leading helpers

ExportRole
timelineHorizontalLeadingItemClassNameDrop the default top margin
timelineHorizontalLeadingSeparatorClassNameRail at top-8
timelineHorizontalLeadingIndicatorClassNameDot at top-8
timelineHorizontalLeadingDateClassNameDate above the rail (mb-10)

Parts

ExportRole
TimelineSeparatorConnector
TimelineHeaderTitle + indicator
TimelineIndicatorDefault size-4 dot; override for icons
TimelineOppositeActivity-log timestamp column (vertical) — date and time as one string. Item gutter becomes ms-40
TimelineDateTimestamp on the content side (header or body)
TimelineTitleEvent title (h3)
TimelineContentBody + date
TimelineMetaOptional actor row
TimelineDescriptionOptional secondary body

See Also

On this page