# Timeline

A vertical timeline for activity feeds and status history.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/timeline.json
```

[Registry JSON](https://mwui.vercel.app/r/timeline.json)

## Preview

```tsx
import { IconAlertTriangle, IconCheck, IconTruck } from "@tabler/icons-react";

import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineIndicator,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";

export function Preview() {
  return (
    <Timeline className="w-full max-w-sm">
      <TimelineItem status="complete">
        <TimelineConnector />
        <TimelineIndicator>
          <IconCheck className="size-3" />
        </TimelineIndicator>
        <TimelineContent>
          <TimelineTitle>Order placed</TimelineTitle>
          <TimelineDescription>Payment authorized for €48.00.</TimelineDescription>
          <TimelineTime date="2026-08-14T09:12:00Z">14 Aug, 09:12</TimelineTime>
        </TimelineContent>
      </TimelineItem>

      <TimelineItem status="error">
        <TimelineConnector />
        <TimelineIndicator>
          <IconAlertTriangle className="size-3" />
        </TimelineIndicator>
        <TimelineContent>
          <TimelineTitle>Delivery attempt failed</TimelineTitle>
          <TimelineDescription>Nobody was home. A second attempt is scheduled.</TimelineDescription>
          <TimelineTime date="2026-08-16T14:03:00Z">16 Aug, 14:03</TimelineTime>
        </TimelineContent>
      </TimelineItem>

      <TimelineItem status="current">
        <TimelineConnector />
        <TimelineIndicator>
          <IconTruck className="size-3" />
        </TimelineIndicator>
        <TimelineContent>
          <TimelineTitle>Out for delivery</TimelineTitle>
          <TimelineDescription>Expected before 18:00.</TimelineDescription>
          <TimelineTime date="2026-08-18T08:40:00Z">Today, 08:40</TimelineTime>
        </TimelineContent>
      </TimelineItem>

      <TimelineItem status="pending">
        <TimelineConnector />
        <TimelineIndicator />
        <TimelineContent>
          <TimelineTitle>Delivered</TimelineTitle>
        </TimelineContent>
      </TimelineItem>
    </Timeline>
  );
}
```


## Source

### ui/timeline.tsx

```tsx
"use client";

import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";

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

type TimelineStatus = "complete" | "current" | "pending" | "error";

const TimelineItemContext = React.createContext<{ status: TimelineStatus }>({
  status: "complete",
});

function Timeline({ className, ...props }: React.ComponentProps<"ol">) {
  return <ol data-slot="timeline" className={cn("flex flex-col", className)} {...props} />;
}

type TimelineItemProps = React.ComponentProps<"li"> & {
  status?: TimelineStatus;
};

function TimelineItem({ status = "complete", className, ...props }: TimelineItemProps) {
  const value = React.useMemo(() => ({ status }), [status]);

  return (
    <TimelineItemContext.Provider value={value}>
      <li
        data-slot="timeline-item"
        data-status={status}
        className={cn(
          "group/timeline-item relative grid grid-cols-[auto_1fr] gap-x-3 pb-6 last:pb-0",
          className,
        )}
        {...props}
      />
    </TimelineItemContext.Provider>
  );
}

const indicatorVariants = cva(
  "relative z-10 mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-background text-[0.625rem] font-medium",
  {
    variants: {
      status: {
        complete: "bg-primary text-primary-foreground",
        current: "bg-background text-foreground ring-2 ring-primary",
        pending: "bg-muted text-muted-foreground",
        error: "bg-destructive/15 text-destructive ring-2 ring-destructive/40",
      },
    },
    defaultVariants: {
      status: "complete",
    },
  },
);

type TimelineIndicatorProps = React.ComponentProps<"div"> &
  Omit<VariantProps<typeof indicatorVariants>, "status">;

function TimelineIndicator({ className, children, ...props }: TimelineIndicatorProps) {
  const { status } = React.useContext(TimelineItemContext);

  return (
    <div
      data-slot="timeline-indicator"
      aria-hidden="true"
      className={cn(indicatorVariants({ status }), className)}
      {...props}
    >
      {children}
    </div>
  );
}

function TimelineConnector({ className, ...props }: React.ComponentProps<"div">) {
  const { status } = React.useContext(TimelineItemContext);

  return (
    <div
      data-slot="timeline-connector"
      aria-hidden="true"
      className={cn(
        "absolute top-6 bottom-1 left-2.5 w-px -translate-x-1/2 group-last/timeline-item:hidden",
        status === "pending" ? "bg-border" : "bg-primary/40",
        className,
      )}
      {...props}
    />
  );
}

function TimelineContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="timeline-content"
      className={cn("flex min-w-0 flex-col gap-1 pt-px", className)}
      {...props}
    />
  );
}

function TimelineTitle({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="timeline-title"
      className={cn("text-sm leading-5 font-medium text-foreground", className)}
      {...props}
    />
  );
}

function TimelineDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="timeline-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}

type TimelineTimeProps = React.ComponentProps<"time"> & {
  date?: Date | string;
};

function TimelineTime({ date, className, children, ...props }: TimelineTimeProps) {
  const resolved = date === undefined ? undefined : new Date(date);

  return (
    <time
      data-slot="timeline-time"
      dateTime={resolved?.toISOString()}
      className={cn("text-xs text-muted-foreground", className)}
      {...props}
    >
      {children ?? resolved?.toLocaleString()}
    </time>
  );
}

export {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineIndicator,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
  type TimelineItemProps,
  type TimelineStatus,
  type TimelineTimeProps,
};
```



## Usage

Composable parts rather than one configured widget, because activity rows never stay simple: one
item needs an avatar, another an inline diff, another a button. You arrange them; the component owns
the rail, the alignment, and the status colors.

```tsx
import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineIndicator,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";

<Timeline>
  <TimelineItem status="complete">
    <TimelineConnector />
    <TimelineIndicator />
    <TimelineContent>
      <TimelineTitle>Order placed</TimelineTitle>
      <TimelineDescription>Payment authorized.</TimelineDescription>
      <TimelineTime>2 hours ago</TimelineTime>
    </TimelineContent>
  </TimelineItem>

  <TimelineItem status="current">
    <TimelineConnector />
    <TimelineIndicator />
    <TimelineContent>
      <TimelineTitle>In transit</TimelineTitle>
    </TimelineContent>
  </TimelineItem>
</Timeline>;
```

## Status

`status` on the item drives the indicator and the connector below it, and reaches them through
context — so nothing has to be threaded down by hand.

| Status     | Reads as                                        |
| ---------- | ----------------------------------------------- |
| `complete` | Done. Filled indicator, solid rail. The default. |
| `current`  | Where things are now. Ringed, hollow indicator.  |
| `pending`  | Not reached yet. Muted indicator, muted rail.    |
| `error`    | Failed at this step.                             |

## Indicators

An empty `TimelineIndicator` is a dot. Put anything inside it — a number, an icon, a tiny avatar —
and it centers automatically:

```tsx
<TimelineIndicator>
  <IconCheck className="size-3" />
</TimelineIndicator>

<TimelineIndicator className="size-7">
  <img src={user.avatar} alt="" className="size-full rounded-full object-cover" />
</TimelineIndicator>
```

Resizing the indicator does not move the rail, so mixed sizes still line up.

## Connectors

`TimelineConnector` draws the line from an item to the next one and hides itself on the last item,
so you can render it in every row without special-casing the end of the list.

## Timestamps

`TimelineTime` accepts a `Date` or an ISO string and writes a machine-readable `dateTime` attribute.
Pass your own text as children whenever the page is server-rendered:

```tsx
<TimelineTime date={event.createdAt}>{formatRelative(event.createdAt)}</TimelineTime>
```

The automatic fallback formats with the viewer's locale and timezone, which the server does not
know — so it will differ between the server render and the client. Text you supply, or a
[useMounted](/utilities/use-mounted) guard, avoids that.

## Accessibility

The timeline is an ordered list and each entry is a list item, which is what makes it navigable as a
sequence rather than a wall of text. Indicators and connectors are `aria-hidden`, so status must also
be present in the text — "Failed to deliver", not a red dot alone.

