Skip to contentVibraUI
Data display

Timeline

An ordered list of events with one hairline rail running behind 24px dots.

Server-compatible: no hooks, no client boundary. Each item draws its own segment of the rail — from under its dot to the next item's dot, and none at all on the last — so the line never shows above the first dot or below the last however tall the rows are; each dot rings itself in the page background to sit over it. Tone is the item's own axis: default is near-ink for an ordinary step and neutral is quieter still, with the four status tones reserved for steps that actually carry status. title is content, not the HTML tooltip attribute, so it is omitted from the li props. Children render under the description, for anything a description cannot hold.

Install

npx shadcn@latest add @vibra/timeline

Needs the @vibra registry in your components.json — set it up once.

Examples

Tones

All six dots side by side, from the near-ink default to danger.

Props

PropTypeDefaultDescription
Timeline.classNamestring—Merged onto the ol root, which draws the rail; the remaining ol props are spread onto it.
titleReact.ReactNode—The event itself — what happened.
descriptionReact.ReactNode—A quieter second line under the title.
timeReact.ReactNode—Trails the title — a timestamp, a duration, a version.
iconReact.ReactNode—Sits inside the dot. Defaults to a small filled circle.
tone"default" | "neutral" | "success" | "warning" | "danger" | "info""default"Paints the dot from its muted background and matching foreground.
childrenReact.ReactNode—Extra content under the description — a diff box, a set of tags, a chart.

Dependencies

Source

components/ui/timeline.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { CircleIcon } from "lucide-react"

import { cn } from "@/lib/utils"

const timelineDotVariants = cva(
  "relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full ring-4 ring-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
  {
    variants: {
      tone: {
        // Near-ink for an ordinary step: colour stays reserved for status.
        default: "bg-muted text-foreground",
        neutral: "bg-muted text-muted-foreground",
        success: "bg-success-muted text-success",
        warning: "bg-warning-muted text-warning",
        danger: "bg-danger-muted text-danger",
        info: "bg-info-muted text-info",
      },
    },
    defaultVariants: { tone: "default" },
  }
)

export type TimelineTone = NonNullable<VariantProps<typeof timelineDotVariants>["tone"]>

/** An ordered list of events with one hairline rail running behind the dots. */
function Timeline({ className, ...props }: React.ComponentProps<"ol">) {
  return (
    <ol
      data-slot="timeline"
      className={cn("flex flex-col", className)}
      {...props}
    />
  )
}

// `title` is content here, not the HTML tooltip attribute.
export type TimelineItemProps = Omit<React.ComponentProps<"li">, "title"> & {
  /** Sits inside the dot. Defaults to a small filled circle. */
  icon?: React.ReactNode
  tone?: TimelineTone
  /** Trails the title — a timestamp, a duration, a version. */
  time?: React.ReactNode
  title: React.ReactNode
  description?: React.ReactNode
}

/** One event on a Timeline: a dot, a title with its time, and an optional second line. */
function TimelineItem({
  className,
  icon,
  tone = "default",
  time,
  title,
  description,
  children,
  ...props
}: TimelineItemProps) {
  return (
    <li
      data-slot="timeline-item"
      data-tone={tone}
      // Each item draws its own segment of the rail, from under its dot to the
      // next item's dot, and the last item draws none — so the line never
      // shows above the first dot or below the last however tall the rows are.
      className={cn(
        "relative flex gap-3 pb-5 last:pb-0",
        "before:absolute before:top-6 before:bottom-0 before:start-[11px] before:w-px before:bg-border last:before:hidden",
        className
      )}
      {...props}
    >
      <span data-slot="timeline-item-dot" className={cn(timelineDotVariants({ tone }))}>
        {icon ?? (
          <CircleIcon
            data-slot="timeline-item-default-icon"
            aria-hidden="true"
            className="size-2 fill-current"
          />
        )}
      </span>

      <div data-slot="timeline-item-content" className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
        <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
          <span data-slot="timeline-item-title" className="text-sm font-medium">
            {title}
          </span>
          {time ? (
            <span
              data-slot="timeline-item-time"
              className="text-xs tabular-nums whitespace-nowrap text-muted-foreground"
            >
              {time}
            </span>
          ) : null}
        </div>
        {description ? (
          <div data-slot="timeline-item-description" className="text-sm text-muted-foreground">
            {description}
          </div>
        ) : null}
        {children}
      </div>
    </li>
  )
}

export { Timeline, TimelineItem, timelineDotVariants }