Skip to the content

ComponentsData

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"

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.

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.

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

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.

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.

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.

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.

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

Keyboard
KeyBehaviour

API

Timeline

Renders a ol and passes it every other prop.

Timeline props
PropTypeDefaultDescription
align"start" | "end" | "alternate"startThe 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"verticalvertical 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.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--borderborder, background
--cardbackground
--muted-foregroundtext
--primarybackground