HextaUI

BetaBuilt on Vercel’s UI SDK 0.1.0-beta.2. Its API may change.

Timeline

A timeline on Vercel's UI SDK for activity, order progress and agent steps, vertical or horizontal, with statuses, timestamps, icon markers and a feed mode.

Depends on
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
Deployed to production
Version 2.4.1 is live in every region.
Checks passed
312 tests and the bundle size budget.
Pull request merged
Ada merged Cache search results into main.
Review requested
Grace and Linus were asked to review.
import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/ui-sdk-timeline"

const events = [
  {
    title: "Deployed to production",
    dateTime: "2026-11-10T14:32",
    time: "Today, 2:32 PM",
    description: "Version 2.4.1 is live in every region.",
  },
  {
    title: "Checks passed",
    dateTime: "2026-11-10T14:20",
    time: "Today, 2:20 PM",
    description: "312 tests and the bundle size budget.",
  },
  {
    title: "Pull request merged",
    dateTime: "2026-11-10T13:58",
    time: "Today, 1:58 PM",
    description: "Ada merged Cache search results into main.",
  },
  {
    title: "Review requested",
    dateTime: "2026-11-09T17:05",
    time: "Yesterday, 5:05 PM",
    description: "Grace and Linus were asked to review.",
  },
]

function UiSdkTimelineDemo() {
  return (
    <Timeline aria-label="Activity" className="w-full max-w-sm">
      {events.map((event) => (
        <TimelineItem key={event.title}>
          <TimelineMarker />
          <TimelineConnector />
          <TimelineTitle>{event.title}</TimelineTitle>
          <TimelineTime dateTime={event.dateTime}>{event.time}</TimelineTime>
          <TimelineDescription>{event.description}</TimelineDescription>
        </TimelineItem>
      ))}
    </Timeline>
  )
}

export { UiSdkTimelineDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-timeline.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

UI SDK adds a little CSS of its own in a cascade layer named ui-sdk. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

@layer ui-sdk;

@import "tailwindcss";
import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/ui-sdk-timeline"
<Timeline aria-label="Activity">
  <TimelineItem>
    <TimelineMarker />
    <TimelineConnector />
    <TimelineTitle>Deployed to production</TimelineTitle>
    <TimelineTime dateTime="2026-11-10T14:32">2:32 PM</TimelineTime>
    <TimelineDescription>Version 2.4.1 is live.</TimelineDescription>
  </TimelineItem>
</Timeline>

A timeline lists things that happened, or will, like an activity feed, an order’s progress or an agent’s steps. It’s a list for screen readers with one tab stop, and the arrow keys move between events. Pass dateTime to TimelineTime so the date is machine readable while the text stays friendly. UI SDK marks it experimental in 0.1.0-beta.2.

Timeline
├── TimelineItem
│   ├── TimelineMarker
│   ├── TimelineConnector
│   ├── TimelineTitle
│   ├── TimelineTime
│   └── TimelineDescription
└── TimelineItem

Every part inside an item is optional. The marker sits in its own column and the connector runs from it to the next event, hidden after the last one. Anything else you put in an item, like a button or a collapsible, lines up with the title.

Give each item a status of complete, current or upcoming. The current event gets aria-current="step" and the tab stop, and a controller focuses an event by its value. Say the status in the title too, since the marker only shows it.

"use client"

import * as React from "react"
import { IconCheck } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
  createTimelineController,
  type TimelineStatus,
} from "@/components/ui/ui-sdk-timeline"

const steps: {
  value: string
  title: string
  dateTime: string
  time: string
  description: string
  status: TimelineStatus
}[] = [
  {
    value: "placed",
    title: "Order placed",
    dateTime: "2026-11-01",
    time: "Nov 1",
    description: "We received your order.",
    status: "complete",
  },
  {
    value: "packed",
    title: "Packed",
    dateTime: "2026-11-02",
    time: "Nov 2",
    description: "Your items left the shelf.",
    status: "complete",
  },
  {
    value: "shipped",
    title: "Shipped, in transit",
    dateTime: "2026-11-03",
    time: "Nov 3",
    description: "On its way with the carrier.",
    status: "current",
  },
  {
    value: "delivered",
    title: "Delivered",
    dateTime: "2026-11-05",
    time: "Expected Nov 5",
    description: "Expected by Thursday.",
    status: "upcoming",
  },
]

function UiSdkTimelineStatus() {
  const [controller] = React.useState(createTimelineController)

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Button
        size="sm"
        variant="outline"
        className="self-start"
        onClick={() => controller.focus("shipped")}
      >
        Go to current step
      </Button>
      <Timeline aria-label="Order status" controller={controller}>
        {steps.map((step) => (
          <TimelineItem
            key={step.value}
            value={step.value}
            status={step.status}
          >
            <TimelineMarker>
              {step.status === "complete" && <IconCheck aria-hidden="true" />}
            </TimelineMarker>
            <TimelineConnector />
            <TimelineTitle>{step.title}</TimelineTitle>
            <TimelineTime dateTime={step.dateTime}>{step.time}</TimelineTime>
            <TimelineDescription>{step.description}</TimelineDescription>
          </TimelineItem>
        ))}
      </Timeline>
    </div>
  )
}

export { UiSdkTimelineStatus }

An icon in TimelineMarker turns it into a small circle. Put a spinner in the current step while an agent works, and a duration like PT5S in dateTime.

import {
  IconCheck,
  IconFileText,
  IconGitPullRequest,
  IconWorld,
} from "@tabler/icons-react"

import { Spinner } from "@/components/ui/spinner"
import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/ui-sdk-timeline"

function UiSdkTimelineAgent() {
  return (
    <Timeline aria-label="Agent run" className="w-full max-w-sm">
      <TimelineItem status="complete">
        <TimelineMarker>
          <IconFileText aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Read 4 files</TimelineTitle>
        <TimelineTime dateTime="PT2S">2s</TimelineTime>
        <TimelineDescription>
          search.ts, cache.ts, cache.test.ts and README.md
        </TimelineDescription>
      </TimelineItem>
      <TimelineItem status="complete">
        <TimelineMarker>
          <IconWorld aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Searched the web</TimelineTitle>
        <TimelineTime dateTime="PT5S">5s</TimelineTime>
        <TimelineDescription>
          3 results for “LRU cache eviction in edge runtimes”
        </TimelineDescription>
      </TimelineItem>
      <TimelineItem status="current">
        <TimelineMarker>
          <Spinner size="sm" aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Writing the fix, in progress</TimelineTitle>
        <TimelineDescription>
          Evicting the oldest entry once the cache holds 500 results.
        </TimelineDescription>
      </TimelineItem>
      <TimelineItem status="upcoming">
        <TimelineMarker>
          <IconGitPullRequest aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Open a pull request</TimelineTitle>
      </TimelineItem>
      <TimelineItem status="upcoming">
        <TimelineMarker>
          <IconCheck aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Run the checks</TimelineTitle>
      </TimelineItem>
    </Timeline>
  )
}

export { UiSdkTimelineAgent }

The marker’s variant is success, warning, destructive or info, for an empty dot or an icon.

import {
  IconAlertTriangle,
  IconCheck,
  IconInfoCircle,
  IconRocket,
  IconX,
} from "@tabler/icons-react"

import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/ui-sdk-timeline"

function UiSdkTimelineMarkers() {
  return (
    <Timeline aria-label="Deploy history" className="w-full max-w-sm">
      <TimelineItem>
        <TimelineMarker variant="success">
          <IconCheck aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Deploy succeeded</TimelineTitle>
        <TimelineTime dateTime="2026-11-12T09:41">Nov 12, 9:41 AM</TimelineTime>
      </TimelineItem>
      <TimelineItem>
        <TimelineMarker variant="warning">
          <IconAlertTriangle aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Slow build warning</TimelineTitle>
        <TimelineTime dateTime="2026-11-12T09:37">Nov 12, 9:37 AM</TimelineTime>
        <TimelineDescription>
          The build took 6 minutes, 2 over budget.
        </TimelineDescription>
      </TimelineItem>
      <TimelineItem>
        <TimelineMarker variant="destructive">
          <IconX aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Deploy failed</TimelineTitle>
        <TimelineTime dateTime="2026-11-11T18:02">Nov 11, 6:02 PM</TimelineTime>
        <TimelineDescription>
          A migration timed out and was rolled back.
        </TimelineDescription>
      </TimelineItem>
      <TimelineItem>
        <TimelineMarker variant="info">
          <IconInfoCircle aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Maintenance window set</TimelineTitle>
        <TimelineTime dateTime="2026-11-11T12:00">
          Nov 11, 12:00 PM
        </TimelineTime>
      </TimelineItem>
      <TimelineItem>
        <TimelineMarker>
          <IconRocket aria-hidden="true" />
        </TimelineMarker>
        <TimelineConnector />
        <TimelineTitle>Project created</TimelineTitle>
        <TimelineTime dateTime="2026-11-01T10:15">Nov 1, 10:15 AM</TimelineTime>
      </TimelineItem>
    </Timeline>
  )
}

export { UiSdkTimelineMarkers }

orientation="horizontal" lays the events in a row, like a roadmap. Left and Right move between them, and loop={false} stops at the ends.

import { IconCheck } from "@tabler/icons-react"

import {
  Timeline,
  TimelineConnector,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
  type TimelineStatus,
} from "@/components/ui/ui-sdk-timeline"

const milestones: {
  title: string
  dateTime: string
  time: string
  status: TimelineStatus
}[] = [
  {
    title: "Kickoff",
    dateTime: "2026-01",
    time: "January",
    status: "complete",
  },
  {
    title: "Design review",
    dateTime: "2026-03",
    time: "March",
    status: "complete",
  },
  { title: "Beta", dateTime: "2026-06", time: "June", status: "current" },
  {
    title: "Launch",
    dateTime: "2026-09",
    time: "September",
    status: "upcoming",
  },
]

function UiSdkTimelineHorizontal() {
  return (
    <Timeline
      orientation="horizontal"
      loop={false}
      aria-label="Roadmap"
      className="w-full max-w-xl"
    >
      {milestones.map((milestone) => (
        <TimelineItem key={milestone.title} status={milestone.status}>
          <TimelineMarker>
            {milestone.status === "complete" && (
              <IconCheck aria-hidden="true" />
            )}
          </TimelineMarker>
          <TimelineConnector />
          <TimelineTitle>{milestone.title}</TimelineTitle>
          <TimelineTime dateTime={milestone.dateTime}>
            {milestone.time}
          </TimelineTime>
        </TimelineItem>
      ))}
    </Timeline>
  )
}

export { UiSdkTimelineHorizontal }

Links and buttons inside an event stay in the tab order. Tab moves from the event to them, and the arrow keys still move between events.

import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  CollapsibleTriggerIcon,
} from "@/components/ui/collapsible"
import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/ui-sdk-timeline"

const releases = [
  {
    title: "Version 2.4",
    dateTime: "2026-10-28",
    time: "October 28",
    description: "Faster search and resumable uploads.",
    changes: [
      "Search now matches file contents, not only names.",
      "Uploads resume after the connection drops.",
    ],
  },
  {
    title: "Version 2.3",
    dateTime: "2026-09-30",
    time: "September 30",
    description: "Shared links and a new sidebar.",
    changes: [
      "Shared links remember the folder they were opened from.",
      "The sidebar collapses to icons on narrow screens.",
    ],
  },
  {
    title: "Version 2.2",
    dateTime: "2026-08-26",
    time: "August 26",
    description: "Dark mode follows your system.",
    changes: ["Dark mode follows the system setting.", "Avatars are sharper."],
  },
]

function UiSdkTimelineDetails() {
  return (
    <Timeline aria-label="Changelog" className="w-full max-w-sm">
      {releases.map((release) => (
        <TimelineItem key={release.title}>
          <TimelineMarker />
          <TimelineConnector />
          <TimelineTitle>{release.title}</TimelineTitle>
          <TimelineTime dateTime={release.dateTime}>
            {release.time}
          </TimelineTime>
          <TimelineDescription>{release.description}</TimelineDescription>
          <Collapsible className="flex flex-col items-start gap-1 pt-1">
            <CollapsibleTrigger
              render={<Button variant="ghost" size="sm" />}
              className="-ms-2"
            >
              What changed
              <CollapsibleTriggerIcon data-icon="inline-end" />
            </CollapsibleTrigger>
            <CollapsibleContent>
              <ul className="m-0 flex list-disc flex-col gap-1 ps-4 text-sm text-muted-foreground">
                {release.changes.map((change) => (
                  <li key={change}>{change}</li>
                ))}
              </ul>
            </CollapsibleContent>
          </Collapsible>
        </TimelineItem>
      ))}
    </Timeline>
  )
}

export { UiSdkTimelineDetails }

feed turns the list into a feed of articles for timelines that grow, and Page Down and Page Up move between them. Set busy while more load.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/ui-sdk-timeline"

const updates = [
  {
    id: 1,
    author: "Ada",
    date: "2026-11-10",
    time: "Nov 10",
    text: "Shipped the new onboarding flow.",
  },
  {
    id: 2,
    author: "Grace",
    date: "2026-11-10",
    time: "Nov 10",
    text: "Fixed the flaky upload test.",
  },
  {
    id: 3,
    author: "Linus",
    date: "2026-11-09",
    time: "Nov 9",
    text: "Moved the docs to the new theme.",
  },
  {
    id: 4,
    author: "Margaret",
    date: "2026-11-09",
    time: "Nov 9",
    text: "Wrote the launch checklist.",
  },
  {
    id: 5,
    author: "Ada",
    date: "2026-11-08",
    time: "Nov 8",
    text: "Cut search latency in half.",
  },
  {
    id: 6,
    author: "Grace",
    date: "2026-11-07",
    time: "Nov 7",
    text: "Added keyboard shortcuts to the editor.",
  },
  {
    id: 7,
    author: "Linus",
    date: "2026-11-06",
    time: "Nov 6",
    text: "Cleaned up the old billing code.",
  },
  {
    id: 8,
    author: "Margaret",
    date: "2026-11-05",
    time: "Nov 5",
    text: "Ran the accessibility review.",
  },
  {
    id: 9,
    author: "Ada",
    date: "2026-11-04",
    time: "Nov 4",
    text: "Started the mobile layout.",
  },
]

const pageSize = 3

function UiSdkTimelineFeed() {
  const [count, setCount] = React.useState(pageSize)
  const [busy, setBusy] = React.useState(false)
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Timeline feed busy={busy} aria-label="Team updates">
        {updates.slice(0, count).map((update) => (
          <TimelineItem key={update.id}>
            <TimelineMarker />
            <TimelineConnector />
            <TimelineTitle>{update.author}</TimelineTitle>
            <TimelineTime dateTime={update.date}>{update.time}</TimelineTime>
            <TimelineDescription>{update.text}</TimelineDescription>
          </TimelineItem>
        ))}
      </Timeline>
      <Button
        size="sm"
        variant="outline"
        className="self-start"
        disabled={busy || count >= updates.length}
        onClick={() => {
          setBusy(true)
          timer.current = setTimeout(() => {
            setCount((current) => current + pageSize)
            setBusy(false)
          }, 600)
        }}
      >
        {busy
          ? "Loading…"
          : count >= updates.length
            ? "All caught up"
            : "Load more"}
      </Button>
    </div>
  )
}

export { UiSdkTimelineFeed }

The markers and the line move to the right side.

import {
  Timeline,
  TimelineConnector,
  TimelineDescription,
  TimelineItem,
  TimelineMarker,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/ui-sdk-timeline"

const events = [
  {
    title: "تم النشر",
    dateTime: "2026-11-10T14:32",
    time: "اليوم، 2:32 م",
    description: "الإصدار 2.4.1 متاح الآن في كل المناطق.",
  },
  {
    title: "نجحت الفحوصات",
    dateTime: "2026-11-10T14:20",
    time: "اليوم، 2:20 م",
    description: "اجتاز 312 اختبارًا.",
  },
  {
    title: "تم دمج الطلب",
    dateTime: "2026-11-10T13:58",
    time: "اليوم، 1:58 م",
    description: "دمجت آدا التغييرات في الفرع الرئيسي.",
  },
]

function UiSdkTimelineRtl() {
  return (
    <div dir="rtl" lang="ar" className="w-full max-w-sm">
      <Timeline aria-label="النشاط">
        {events.map((event) => (
          <TimelineItem key={event.title}>
            <TimelineMarker />
            <TimelineConnector />
            <TimelineTitle>{event.title}</TimelineTitle>
            <TimelineTime dateTime={event.dateTime}>{event.time}</TimelineTime>
            <TimelineDescription>{event.description}</TimelineDescription>
          </TimelineItem>
        ))}
      </Timeline>
    </div>
  )
}

export { UiSdkTimelineRtl }
KeyAction
↓↑Move between events in a vertical timeline.
→←Move between events in a horizontal timeline. They swap in right-to-left text.
HomeEndFocus the first or last event.
PageDownPageUpIn a feed, focus the next or previous article, from anywhere inside one.
TabEnters on the current event, else the first, then moves to the links and buttons inside it.

A timeline is a list named with aria-label, or a feed of articles with their positions. Each event is named by its title and described by its time and description, and the markers and connectors are hidden from screen readers.

The focused event shows a ring with room around it. Marker colors fade between statuses only when motion is allowed, and in forced colors the dots and lines draw in the text color.

Each part wraps the UI SDK part of the same name and accepts all of its props, including render and a className that can be a function of the part’s state.

PropTypeDefault
orientation
"vertical" | "horizontal""vertical"
loop
booleantrue
feed
booleanfalse
busySets aria-busy on a feed while it loads.
booleanfalse
controllerFrom createTimelineController().
TimelineController–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="timeline"Target the timeline in CSS.
data-orientationvertical or horizontal.
data-busyPresent while a feed is busy.
--timeline-countHow many events are in the page.
PropTypeDefault
valueNames the event for the controller’s focus().
string–
status
"complete" | "current" | "upcoming"–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="timeline-item"Target an event in CSS.
data-completePresent when the event’s status is complete.
data-currentPresent when the event’s status is current.
data-upcomingPresent when the event’s status is upcoming.
data-firstPresent on the first event once it’s in the page.
data-lastPresent on the last event once it’s in the page.
--timeline-indexThe event’s place from 0.
PropTypeDefault
variant
"default" | "success" | "warning" | "destructive" | "info""default"
childrenAn icon or image. Empty draws a dot.
ReactNode–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="timeline-marker"Target the marker in CSS.
data-variantThe marker’s variant.
data-completePresent when the event’s status is complete.
data-currentPresent when the event’s status is current.
data-upcomingPresent when the event’s status is upcoming.

The line from the marker to the next event. It’s solid under a complete event and hidden after the last.

Names its event. Render it as a heading with render={<h3 />} so people can move between events by heading.

PropTypeDefault
dateTimeA machine-readable date, time or duration. It describes the event.
string–

More about its event. It describes the event.