react ~9 KB 0 deps v1.2.0 โ†— GitHub โ†—

@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 #

  1. Order placed

    Confirmation email sent.

  2. Processing

    Picking from warehouse #4.

  3. Awaiting confirmation

Props
TSX
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).

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 #

PropTypeDefaultDescription
itemsTimelineItem[]โ€”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
activeIdstringโ€”Marks the matching item with aria-current="step"
pendingIdstringโ€”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.
pendingIconReactNodespinnerReplace the spinner used for the pendingId item.
reversebooleanfalseNewest first
filterstring | (item) => booleanโ€”Hide non-matching items. String matches title/description/date.
groupBy"groupId" | (item) => stringโ€”Cluster items under a sticky header
groupLabelsRecord<string, ReactNode>โ€”Map group id โ†’ label
spacing"uniform" | "time""uniform"Equal gaps or proportional to item.timestamp
defaultExpandedstring[][]Pre-expanded ids (uncontrolled)
expandedstring[]โ€”Controlled expanded ids
onExpandedChange(ids) => voidโ€”Pair with expanded for controlled mode
expansionMode"single" | "multiple""multiple"One open at a time vs. many
animatebooleanfalseStagger 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 #

FieldTypeDescription
idstringRequired, unique
titleReactNodeRequired
descriptionReactNode?Sub-text under the title
dateReactNode?Date label above the title
oppositeReactNode?Right-side content (vertical layout only)
detailsReactNode?Expandable body โ€” when present, item becomes toggleable
iconReactNode?Custom icon inside the dot
status"default" | "active" | "completed" | "error" | "warning"?Status color + default icon
groupIdstring?Used with groupBy="groupId"
timestampDate | number?Used with spacing="time"
disabledboolean?Greyed out, click ignored
dotReactNode?Replace the entire dot
dataT?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 #

KeyAction
โ†“ / โ†‘ (or โ†’ / โ† horizontal)Move focus between items
Home / EndJump to first / last
Enter / SpaceToggle 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.

Edit this page on GitHub