@mshafiqyajid/react-timeline
A serious, batteries-included timeline. Grouping with sticky headers, expandable items, pending tail, time-proportional layout, opposite content, animated reveal, infinite-scroll sentinel, and full keyboard navigation. Headless hook + styled component.
Playground #
Order placed
Confirmation email sent.
Payment captured
Stripe charge ch_3OqK2pAB12 โ $124.00 (USD)Processing
Picking from warehouse #4.
Shipped
Tracking 1Z999AA1 ยท UPS Ground ยท ETA Wed.Awaiting confirmation
import { TimelineStyled } from "@mshafiqyajid/react-timeline/styled";
import "@mshafiqyajid/react-timeline/styles.css";
<TimelineStyled
items={items}
tone="primary"
/>Install #
npm install @mshafiqyajid/react-timeline Quick start #
import { TimelineStyled } from "@mshafiqyajid/react-timeline/styled";
import type { TimelineItem } from "@mshafiqyajid/react-timeline";
import "@mshafiqyajid/react-timeline/styles.css";
const items: TimelineItem[] = [
{ id: "1", title: "Order placed", date: "Jan 1", status: "completed" },
{ id: "2", title: "Processing", date: "Jan 2", status: "active",
details: "Picking from warehouse #4." },
{ id: "3", title: "Shipped", date: "Jan 3", status: "default" },
];
<TimelineStyled items={items} tone="primary" /> Pending tail #
Render a spinner + dashed connector for the last item to indicate "more events coming" โ the AntD pattern.
<TimelineStyled
items={[...completed, { id: "next", title: "Awaiting confirmation" }]}
pendingId="next"
/> Grouping with sticky headers #
<TimelineStyled
items={items} // each item has groupId: "today" | "earlier"
groupBy="groupId"
groupLabels={{ today: "Today", earlier: "Earlier this week" }}
/> Expandable items #
When an item has details, the row becomes toggleable. Click anywhere on the item, hit Enter/Space when focused, or click the chevron.
<TimelineStyled
items={[
{ id: "1", title: "Deployed v0.4.2",
details: <DeployLogs id="1" /> },
]}
expansionMode="single" // or "multiple"
defaultExpanded={["1"]}
/> Time-proportional layout #
Pass each item a timestamp and set spacing="time" โ items get vertical-margin proportional to the gap between events. Great for activity feeds.
const items = events.map((e) => ({
id: e.id,
title: e.label,
timestamp: e.at, // Date | number
}));
<TimelineStyled items={items} spacing="time" /> Custom status icons #
<TimelineStyled
items={items}
statusIcons={{
completed: <CheckCircle size={12} />,
error: <XCircle size={12} />,
warning: <AlertTriangle size={12} />,
default: <span>{/* render step number */}</span>,
}}
pendingIcon={<MyBrandSpinner />} // overrides the spinner for pendingId
/> Resolution order: item.icon โ statusIcons[status] โ built-in default. Setting statusIcons.active suppresses the default pulse ring.
Programmatic control #
import { useRef } from "react";
import { TimelineStyled, type TimelineHandle }
from "@mshafiqyajid/react-timeline/styled";
const ref = useRef<TimelineHandle>(null);
<>
<button onClick={() => ref.current?.scrollToId("step-3")}>
Jump to step 3
</button>
<TimelineStyled ref={ref} items={items} />
</> Infinite scroll #
<TimelineStyled
items={items}
onLoadMore={() => fetchOlder()}
/> An IntersectionObserver watches a sentinel after the last item; onLoadMore fires when it crosses the viewport bottom (200 px ahead).
Filter / search #
const [query, setQuery] = useState("");
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<TimelineStyled items={items} filter={query} /> String filter matches against title, description, and date. Pass a predicate (item) => boolean for full control.
Headless #
import { useTimeline } from "@mshafiqyajid/react-timeline";
const tl = useTimeline({
items,
groupBy: "groupId",
expansionMode: "single",
});
return (
<ol {...tl.getRootProps()}>
{tl.groups.map((g) => (
<section key={g.id}>
<h3>{g.label}</h3>
{g.items.map((it) => (
<li key={it.id} {...tl.getItemProps(it.id)}>
<span {...tl.getDotProps(it.id)} />
<p>{it.title}</p>
{it.details && (
<>
<button {...tl.getToggleProps(it.id)}>Details</button>
{tl.isExpanded(it.id) && <div>{it.details}</div>}
</>
)}
</li>
))}
</section>
))}
</ol>
); API #
| Prop | Type | Default | Description |
|---|---|---|---|
| items | TimelineItem[] | โ | Items to render. See TimelineItem below. |
| orientation | "vertical" | "horizontal" | "vertical" | Layout direction |
| size | "sm" | "md" | "lg" | "md" | Dot and text size |
| tone | "neutral" | "primary" | "success" | "danger" | "neutral" | Active item colour |
| connector | "line" | "dashed" | "none" | "line" | Connector style |
| align | "left" | "right" | "center" | "alternate" | "left" | Content alignment (vertical only) |
| density | "compact" | "comfortable" | "spacious" | "comfortable" | Spacing between items |
| dotVariant | "outline" | "solid" | "ring" | "outline" | Dot visual style |
| activeId | string | โ | Marks the matching item with aria-current="step" |
| pendingId | string | โ | Renders the matching item as the pending tail (spinner + dashed connector) |
| statusIcons | { completed?, error?, warning?, active?, default? } | โ | Override the built-in dot icons globally. item.icon still wins. |
| pendingIcon | ReactNode | spinner | Replace the spinner used for the pendingId item. |
| reverse | boolean | false | Newest first |
| filter | string | (item) => boolean | โ | Hide non-matching items. String matches title/description/date. |
| groupBy | "groupId" | (item) => string | โ | Cluster items under a sticky header |
| groupLabels | Record<string, ReactNode> | โ | Map group id โ label |
| spacing | "uniform" | "time" | "uniform" | Equal gaps or proportional to item.timestamp |
| defaultExpanded | string[] | [] | Pre-expanded ids (uncontrolled) |
| expanded | string[] | โ | Controlled expanded ids |
| onExpandedChange | (ids) => void | โ | Pair with expanded for controlled mode |
| expansionMode | "single" | "multiple" | "multiple" | One open at a time vs. many |
| animate | boolean | false | Stagger fade-in on mount (respects reduced motion) |
| onItemClick | (item) => void | โ | Click handler โ fires alongside expand toggle |
| onLoadMore | () => void | โ | Fired when end-of-list sentinel enters viewport |
| renderItem | (ctx) => ReactNode | โ | Replace item body content |
TimelineItem #
| Field | Type | Description |
|---|---|---|
| id | string | Required, unique |
| title | ReactNode | Required |
| description | ReactNode? | Sub-text under the title |
| date | ReactNode? | Date label above the title |
| opposite | ReactNode? | Right-side content (vertical layout only) |
| details | ReactNode? | Expandable body โ when present, item becomes toggleable |
| icon | ReactNode? | Custom icon inside the dot |
| status | "default" | "active" | "completed" | "error" | "warning"? | Status color + default icon |
| groupId | string? | Used with groupBy="groupId" |
| timestamp | Date | number? | Used with spacing="time" |
| disabled | boolean? | Greyed out, click ignored |
| dot | ReactNode? | Replace the entire dot |
| data | T? | Free-form payload, surfaced in renderItem |
Imperative handle #
interface TimelineHandle {
scrollToId: (id: string, opts?: ScrollIntoViewOptions) => void;
focusItem: (id: string) => void;
expand: (id: string) => void;
collapse: (id: string) => void;
toggle: (id: string) => void;
} Keyboard navigation #
| Key | Action |
|---|---|
| โ / โ (or โ / โ horizontal) | Move focus between items |
| Home / End | Jump to first / last |
| Enter / Space | Toggle expand on items with details |
Migrating from 0.x #
Breaking: the headless useTimeline hook now takes TimelineItem[] (not string[]) and returns prop getters (getRootProps, getItemProps, getDotProps, getToggleProps) plus state and actions instead of a single getItemProps. The styled TimelineStyled retains its 0.2.x props โ all new behaviours are additive โ so styled-only consumers don't have to change anything.