Timeline

Vertical chronological event feed for activity logs, audit trails, release notes, and notification streams. Pure semantic <ol>/<li> with a status-colored indicator and an optional icon override.

Activity log

  1. Pull request merged2 hours ago
    feat(docs): Phase 2b polish — form input demos
  2. Build failed on Vercel3 hours ago
    Attempted to call cn() from the server but cn is on the client
  3. Review requested4 hours ago
    @oscarabcorona requested review from @reviewer
  4. Branch createdyesterday
    ft/component-polish-form-inputs branched from main
  5. Approaching API rate limit5 hours ago
    GitHub REST quota at 85% — switch to GraphQL or wait 12 min for reset.

Release notes feed

  1. v1.4.0 — Per-component bundle splitApr 28
    RSC-safe deep imports, 14 KB shaved from the cold-start bundle.
  2. v1.3.0 — Tokens and themes alignmentApr 14
    Synced with @hex-core/tokens 1.2.0; new midnight + ember presets.

Installation

pnpm
pnpm dlx @hex-core/cli add timeline

Custom icon

Override the default dot with a custom node

tsx
import { Timeline } from "@/components/ui/timeline";

export function Example() {
  return (
    <Timeline
      aria-label="Release notes"
      events={[
        { id: "v1", title: "v1.0", timestamp: "Apr 24", icon: <span>⚡</span> },
        { id: "v2", title: "v1.1", timestamp: "Apr 27", icon: <span>🐛</span>, status: "warning" },
      ]}
    />
  );
}

Grouped by day

Group events by date in the parent and render a sub-Timeline per day. Each cluster gets its own h3 heading so the timeline scans like a journal rather than one long flat feed.

tsx
import { useMemo } from "react";
import { Timeline, type TimelineEvent } from "@/components/ui/timeline";

interface DatedEvent extends TimelineEvent {
  occurredAt: string; // ISO date
}

const events: DatedEvent[] = [
  { id: "a", occurredAt: "2026-04-26T09:00:00Z", title: "Branch created", timestamp: "9:00 AM", status: "info" },
  { id: "b", occurredAt: "2026-04-26T14:20:00Z", title: "PR opened", timestamp: "2:20 PM", status: "info" },
  { id: "c", occurredAt: "2026-04-27T10:05:00Z", title: "Review approved", timestamp: "10:05 AM", status: "success" },
  { id: "d", occurredAt: "2026-04-27T11:42:00Z", title: "Merged to main", timestamp: "11:42 AM", status: "success" },
];

const dayLabel = new Intl.DateTimeFormat("en-US", { dateStyle: "full" });

function groupByDay(items: DatedEvent[]) {
  const buckets = new Map<string, DatedEvent[]>();
  for (const item of items) {
    const key = item.occurredAt.slice(0, 10);
    const bucket = buckets.get(key) ?? [];
    bucket.push(item);
    buckets.set(key, bucket);
  }
  return [...buckets.entries()].sort(([a], [b]) => a.localeCompare(b));
}

export function GroupedActivity() {
  const groups = useMemo(() => groupByDay(events), []);
  return (
    <div className="space-y-6">
      {groups.map(([day, dayEvents]) => (
        <section key={day} className="space-y-3">
          <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
            {dayLabel.format(new Date(day))}
          </h3>
          <Timeline aria-label={`Activity on ${day}`} events={dayEvents} />
        </section>
      ))}
    </div>
  );
}

Variant values

sizeIndicator size
sm
  1. First eventnow
  2. Second event5 min ago
md
  1. First eventnow
  2. Second event5 min ago
ValueDescription
sm
Compact 1.25rem indicator
mddefault
Default 1.75rem indicator

API Reference

PropTypeDefaultDescription
eventsrequired
objectOrdered list of { id, title, timestamp?, description?, icon?, status? } events.
size
stringmdIndicator size: 'sm' | 'md'
aria-labelrequired
stringRequired accessible name for the ordered list (e.g. 'Activity log', 'Release notes')

AI Guidance

When to use

Use to show a chronological event feed: activity logs, audit trails, release notes, notification history, ticket events. Each event has a title and optional timestamp + description.

When not to use

Don't use for project schedules / Gantt charts (build a custom layout). Don't use for navigation between time periods (use Tabs or Stepper). Don't use for paginated data (use Table or DataTable). Don't use for >50 events without virtualization — Timeline renders every item.

Common mistakes

  • Forgetting aria-label — the <ol> needs an accessible name to be understood as a feed
  • Using duplicate event ids — breaks React keys and event reconciliation on re-render
  • Stuffing the description with rich layouts that overflow the timeline rail — keep it short or move to a Card
  • Setting status='error' on every event for emphasis — color loses meaning when overused
  • Mixing controlled timestamps as Date objects without formatting — Timeline accepts ReactNode, so format upstream (date-fns) before passing in

Accessibility

Renders <ol> with the provided aria-label. The status-colored indicator and connector line are aria-hidden — meaning is carried entirely by the title/timestamp/description text. No aria-current; events are historical, not navigational. For >50 events consider a windowing solution outside Timeline.

Related components

Token budget: 1100