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 atimeelement withdateTime. - 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.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--border | border, background |
--card | background |
--muted-foreground | text |
--primary | background |