# Timeline

Shows a sequence of events in order, each a marker on a line with its content beside it.

- **Import:** `import { Timeline, TimelineItem, TimelineSeparator, TimelineMarker, TimelineConnector, TimelineContent, TimelineOpposite } from "@booleanpress/ui/timeline"`
- **Page:** <https://ui.booleanpress.com/components/timeline> · @booleanpress/ui 0.2.0

## Usage

Each `TimelineItem` holds a `TimelineSeparator` (the marker and the line to the next event) and its `TimelineContent`; `TimelineOpposite` adds content on the other side of the line, such as a time.

```tsx
import { Timeline, TimelineContent, TimelineItem, TimelineSeparator } from "@booleanpress/ui/timeline"

export function TicketHistory() {
  return (
    <Timeline aria-label="Ticket history">
      <TimelineItem>
        <TimelineSeparator />
        <TimelineContent>Ticket opened</TimelineContent>
      </TimelineItem>
      <TimelineItem>
        <TimelineSeparator />
        <TimelineContent>Resolved</TimelineContent>
      </TimelineItem>
    </Timeline>
  )
}
```

`align` is the side of the line the content takes: `start` (the default) puts it after the line, `end` before it, and `alternate` switches side on every event. Without opposite content the line stays at the edge; once any event has `TimelineOpposite`, or the events alternate, the line moves to the middle and both sides share the width. `orientation="horizontal"` runs the events across the page, the content below the line (above it with `align="end"`).

An empty `TimelineSeparator` draws the default marker and connector. For a custom marker, give it children: a `TimelineMarker` with an icon, sized and coloured with `className`, or any element such as an `Avatar`, followed by a `TimelineConnector`. The connector is not drawn after the last event.

Format times and dates with `Intl` in the provider's locale (`useUiLocale()`), and put them in a `time` element with a machine-readable `dateTime`.

## Examples

### Basic

A ticket's history: a marker per event, joined by the line, the content after it.

```tsx
import { Timeline, TimelineContent, TimelineItem, TimelineSeparator } from "@booleanpress/ui/timeline"

const HISTORY = ["Ticket opened", "Assigned to Priya", "Customer replied", "Resolved"]

export default function TimelineBasic() {
  return (
    <Timeline aria-label="Ticket history" className="w-full max-w-sm">
      {HISTORY.map((event) => (
        <TimelineItem key={event}>
          <TimelineSeparator />
          <TimelineContent>{event}</TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  )
}
```

### Alignment

`align` puts the content after the line (`start`), before it (`end`) or on alternate sides (`alternate`).

```tsx
import { Timeline, TimelineContent, TimelineItem, TimelineSeparator, type TimelineAlign } from "@booleanpress/ui/timeline"

const STEPS = ["Queued", "Sending", "Delivered", "Opened"]
const ALIGNS: TimelineAlign[] = ["start", "end", "alternate"]

export default function TimelineAlignment() {
  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      {ALIGNS.map((align) => (
        <Timeline key={align} align={align} aria-label={`Message status, content aligned ${align}`}>
          {STEPS.map((step) => (
            <TimelineItem key={step}>
              <TimelineSeparator />
              <TimelineContent>{step}</TimelineContent>
            </TimelineItem>
          ))}
        </Timeline>
      ))}
    </div>
  )
}
```

### Opposite

`TimelineOpposite` puts each event's time on the other side of the line, which moves to the middle.

```tsx
import { useUiLocale } from "@booleanpress/ui/provider"
import {
  Timeline,
  TimelineContent,
  TimelineItem,
  TimelineOpposite,
  TimelineSeparator,
} from "@booleanpress/ui/timeline"

const EVENTS = [
  { status: "Queued", at: "2026-10-15T10:30:00Z" },
  { status: "Sent", at: "2026-10-15T10:31:00Z" },
  { status: "Delivered", at: "2026-10-15T10:32:00Z" },
  { status: "Opened", at: "2026-10-16T08:05:00Z" },
]

export default function TimelineOppositeContent() {
  const { locale, timeZone } = useUiLocale()
  const format = new Intl.DateTimeFormat(locale, { dateStyle: "short", timeStyle: "short", timeZone: timeZone ?? "UTC" })

  return (
    <Timeline aria-label="Delivery of the welcome email" className="w-full max-w-md">
      {EVENTS.map((event) => (
        <TimelineItem key={event.status}>
          <TimelineOpposite className="text-xs/normal">
            <time dateTime={event.at}>{format.format(new Date(event.at))}</time>
          </TimelineOpposite>
          <TimelineSeparator />
          <TimelineContent>{event.status}</TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  )
}
```

### Horizontal

`orientation="horizontal"` runs the delivery steps across the page, with the content below, above or alternating.

```tsx
import { Timeline, TimelineContent, TimelineItem, TimelineSeparator, type TimelineAlign } from "@booleanpress/ui/timeline"

const STEPS = ["Queued", "Sent", "Delivered", "Opened"]
const ALIGNS: TimelineAlign[] = ["start", "end", "alternate"]

export default function TimelineHorizontal() {
  return (
    <div className="flex w-full flex-col gap-6">
      {ALIGNS.map((align) => (
        <Timeline key={align} orientation="horizontal" align={align} aria-label={`Delivery steps, content aligned ${align}`}>
          {STEPS.map((step) => (
            <TimelineItem key={step}>
              <TimelineSeparator />
              <TimelineContent>{step}</TimelineContent>
            </TimelineItem>
          ))}
        </Timeline>
      ))}
    </div>
  )
}
```

### Custom markers

A `TimelineMarker` with an icon and a status colour for each event, and the content in cards on alternate sides.

```tsx
import { CheckIcon, MailIcon, SendIcon, TriangleAlertIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@booleanpress/ui/card"
import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineItem,
  TimelineMarker,
  TimelineOpposite,
  TimelineSeparator,
} from "@booleanpress/ui/timeline"

const EVENTS = [
  { title: "Campaign scheduled", time: "15 Oct, 09:00", text: "The October newsletter is queued for 4,200 subscribers.", Icon: MailIcon, tone: "bg-primary text-primary-foreground" },
  { title: "Sending started", time: "15 Oct, 10:00", text: "Batches of 500 go out every five minutes through the primary mailer.", Icon: SendIcon, tone: "bg-info-solid text-info-solid-foreground" },
  { title: "Rate limit reached", time: "15 Oct, 10:25", text: "The mailer paused for ten minutes, then resumed on its own.", Icon: TriangleAlertIcon, tone: "bg-warning-solid text-warning-solid-foreground" },
  { title: "Campaign sent", time: "15 Oct, 10:55", text: "4,186 delivered, 14 bounced.", Icon: CheckIcon, tone: "bg-success text-success-foreground" },
]

export default function TimelineCustomMarkers() {
  return (
    <Timeline align="alternate" aria-label="Newsletter campaign" className="w-full">
      {EVENTS.map(({ title, time, text, Icon, tone }) => (
        <TimelineItem key={title}>
          <TimelineOpposite>{time}</TimelineOpposite>
          <TimelineSeparator>
            <TimelineMarker className={`size-10 border-0 ${tone} [&_svg:not([class*='size-'])]:size-4`}>
              <Icon />
            </TimelineMarker>
            <TimelineConnector />
          </TimelineSeparator>
          <TimelineContent>
            <Card className="gap-2 border">
              <CardHeader>
                <CardTitle className="text-base/normal">{title}</CardTitle>
              </CardHeader>
              <CardContent className="text-muted-foreground">{text}</CardContent>
            </Card>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  )
}
```

### Activity feed

Avatars as markers, with times relative to a fixed moment, formatted by `Intl.RelativeTimeFormat` in the provider's locale.

```tsx
import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { useUiLocale } from "@booleanpress/ui/provider"
import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineItem,
  TimelineOpposite,
  TimelineSeparator,
} from "@booleanpress/ui/timeline"

// A fixed "now", so the relative times read the same on every render.
const NOW = Date.parse("2026-10-15T12:00:00Z")

const ACTIVITY = [
  { who: "Priya Shah", initials: "PS", action: "replied to ticket #2041", detail: "Sent the new SMTP credentials and asked for a test.", at: "2026-10-15T11:58:00Z" },
  { who: "Tom Becker", initials: "TB", action: "added the mailer Postmark", detail: "Set as the fallback for transactional email.", at: "2026-10-15T11:45:00Z" },
  { who: "Lena Ortiz", initials: "LO", action: "rotated the API key", detail: "The old key stops working on 22 October.", at: "2026-10-15T09:00:00Z" },
  { who: "Sam Okafor", initials: "SO", action: "closed ticket #2033", detail: "Bounce notices were going to an old address.", at: "2026-10-14T16:20:00Z" },
]

function relative(format: Intl.RelativeTimeFormat, iso: string) {
  const minutes = Math.round((Date.parse(iso) - NOW) / 60000)
  if (Math.abs(minutes) < 60) return format.format(minutes, "minute")
  if (Math.abs(minutes) < 1440) return format.format(Math.round(minutes / 60), "hour")
  return format.format(Math.round(minutes / 1440), "day")
}

export default function TimelineActivityFeed() {
  const { locale } = useUiLocale()
  const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })

  return (
    <Timeline aria-label="Recent activity" className="w-full max-w-lg">
      {ACTIVITY.map((item) => (
        <TimelineItem key={item.at}>
          <TimelineOpposite className="pt-1 text-xs/normal">
            <time dateTime={item.at}>{relative(format, item.at)}</time>
          </TimelineOpposite>
          <TimelineSeparator>
            <Avatar>
              <AvatarFallback>{item.initials}</AvatarFallback>
            </Avatar>
            <TimelineConnector />
          </TimelineSeparator>
          <TimelineContent className="pt-1">
            <p>
              <span className="font-medium">{item.who}</span> <span className="text-muted-foreground">{item.action}</span>
            </p>
            <p className="mt-1 text-muted-foreground">{item.detail}</p>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  )
}
```

## Accessibility

**Semantics.** An ordered list (`ol`), one list item (`li`) per event, so a screen reader announces the number of events and each event's place. The opposite content comes first in each item, so a time is read before what happened.

**Labels.** Name the list with `aria-label` ("Ticket history") when the heading above it does not. Put times in a `time` element with `dateTime`.

**Focus.** The timeline takes no focus. Links and buttons inside the content are tab stops in reading order.

**Known limits.**

- The separator is hidden from assistive technology. When a marker's colour or icon means something (failed, done), say it in the content too, for example in visually hidden text.
- Custom markers of different sizes put the line in different places from event to event when the line is at the edge; keep markers one size, or add opposite content so the line is centred.
- A horizontal timeline does not scroll or wrap. Keep it to a few short steps, or switch to vertical on narrow screens.

### Keyboard

| Key | Behaviour |
| --- | --- |

## API

### Timeline

Renders a `ol` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"start" \| "end" \| "alternate"` | `start` | The side of the line the content takes: `start` puts it after the line (to the right in a left-to-right page, below a horizontal line), `end` before it, `alternate` switches side on every event. |
| `orientation` | `"horizontal" \| "vertical"` | `vertical` | `vertical` runs the events down the page; `horizontal` runs them across it. |

### TimelineItem

Renders a `li` and passes it every other prop.

### TimelineSeparator

Renders a `div` and passes it every other prop.

### TimelineMarker

Renders a `span` and passes it every other prop.

### TimelineConnector

Renders a `span` and passes it every other prop.

### TimelineContent

Renders a `div` and passes it every other prop.

### TimelineOpposite

Renders a `div` and passes it every other prop.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="timeline"` (Timeline), `data-slot="timeline-item"` (TimelineItem), `data-slot="timeline-separator"` (TimelineSeparator), `data-slot="timeline-marker"` (TimelineMarker), `data-slot="timeline-connector"` (TimelineConnector), `data-slot="timeline-content"` (TimelineContent), `data-slot="timeline-opposite"` (TimelineOpposite), and `data-align`, `data-orientation`.

## Theming

The marker is a 16 px ring of `--border` on `--card` with a 6 px dot of `--primary`; the connector is a 2 px `--border` line. Content is 14 px `--foreground`, opposite content `--muted-foreground`. A custom marker takes any token classes, such as `bg-success text-success-foreground`.

| Token | Used for |
| --- | --- |
| `--border` | border, background |
| `--card` | background |
| `--muted-foreground` | text |
| `--primary` | background |
