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.
Recommended Scenarios
Pick the job first, then the layout. Examples below are the matching demos.
| Job | Where it lives | How to compose | Example |
|---|---|---|---|
| Activity log (audit of a request) | Object Page Activity tab — one Section Card around the whole track | Vertical; newest first; value={events.length}; TimelineOpposite for the timestamp; title = action; actor in meta; timelineIconActivity* | Activity Log |
| People activity | Same tab, when the actor matters more than the event type | Avatar in the indicator; same Opposite timestamp column | People Activity With Avatars |
| Order / shipment status | Detail body or a status card | Vertical icons; value = current stop; trailing nodes stay pending | Order Status |
| Approval / CI pipeline | In-card progress (not a wizard) | Partial value; unfinished nodes stay open | Pipeline Steps |
| Deploy / job log | Ops panel; expand one step for raw output | Collapsible inside TimelineContent | Collapsible Job Log |
| Roadmap / fundraising milestones | Marketing or planning page | mode="alternate" (centered zig-zag) or compact vertical | Alternate Milestones, Compact Roadmap |
| Release / week strip | Wide header or overview | orientation="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.
- Shell —
SectionCard title="Activity"(or the tab body). One card for the whole track. Loading / Empty / Error stay inside that card (Page And Region Status). - Order — newest first. Set
value={events.length}so every node is completed history. A partialvalueis a pipeline, not an activity log. - Node — title is the action (
Submitted,Returned,Resubmitted,Approved). Actor + seat inTimelineMeta. Return / reject copy lives in the body — not a composer on this rail. - Time — persist an ISO datetime. Render one string in
TimelineOpposite, same tiers as Comment Thread:<24h→2 hours ago; older →MM-DD HH:mm;title→YYYY-MM-DD HH:mm. Do not also putTimelineDateunder the body. Seconds stay out of the UI. - 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.
- Scale — ≤ ~50 events: render the list. Longer: host-owned cursor pages — see Large Activity Feeds.
Approved
Resubmitted
Returned
Submitted
"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
| Area | Behavior |
|---|---|
| Primitive | ReUI defaults: size-4 bordered dots, ms-8 vertical gutter |
| Opposite time | TimelineOpposite — 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 |
| Mode | start (default) · alternate (zig-zag) · end (rail on the right) |
| Icon activity | Apply timelineIconActivityItemClassName, …Separator…, …Indicator… |
| Horizontal leading | Dates above the rail: timelineHorizontalLeading* helpers |
| Steps | TimelineItem step + root value → data-completed |
| Data | Host maps any event array |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/timeline.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/timeline.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/timeline.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/timeline.jsonWith 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
Returned
Submitted
"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
Beta release
Launch
"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
Resubmitted
Returned
Submitted
"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
In transit
Out for delivery
Delivered
"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
Manager review
Finance review
Scheduled
"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
Paid
Shipped
"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.
"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
Tests passed
Job started
"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
Design
Development
"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
Discovery
Build
"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
v1.1
v2.0Current
v2.1
"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 size | Pattern |
|---|---|
| ≤ ~50 events | Full Timeline render |
| ~50–200 | Cursor / keyset pages + Load more (or sentinel); DOM is usually fine for audit rows |
| ≥ ~200 or tall / variable rows | Same 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 usedAPI Reference
Timeline
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Highest completed step. Use items.length for full audit feeds. |
defaultValue | number | 1 | Initial step when uncontrolled. |
onValueChange | (value: number) => void | — | Optional. |
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. |
className | string | — | e.g. w-full max-w-md. |
TimelineItem
| Prop | Type | Default | Description |
|---|---|---|---|
step | number | — | 1-based; completed when step <= value. |
className | string | — | Use timelineIconActivityItemClassName for icon audit. |
Icon activity helpers
| Export | Role |
|---|---|
timelineIconActivityItemClassName | ms-10 gutter for size-6 nodes |
timelineIconActivitySeparatorClassName | Align connector to size-6 |
timelineIconActivityIndicatorClassName | Filled size-6 icon node |
Horizontal leading helpers
| Export | Role |
|---|---|
timelineHorizontalLeadingItemClassName | Drop the default top margin |
timelineHorizontalLeadingSeparatorClassName | Rail at top-8 |
timelineHorizontalLeadingIndicatorClassName | Dot at top-8 |
timelineHorizontalLeadingDateClassName | Date above the rail (mb-10) |
Parts
| Export | Role |
|---|---|
TimelineSeparator | Connector |
TimelineHeader | Title + indicator |
TimelineIndicator | Default size-4 dot; override for icons |
TimelineOpposite | Activity-log timestamp column (vertical) — date and time as one string. Item gutter becomes ms-40 |
TimelineDate | Timestamp on the content side (header or body) |
TimelineTitle | Event title (h3) |
TimelineContent | Body + date |
TimelineMeta | Optional actor row |
TimelineDescription | Optional secondary body |
See Also
- Comment Thread — reply threads with rich text and visibility; not read-only audit
- Stepper — form wizards and guided multi-step flows
- Approval And Case Patterns — when Timeline sits on a Detail Activity tab
- Page And Region Status — Loading / Empty / Error for large feeds