Siteplane UI

Timeline

Present events in chronological order.

Markdown
  1. Ben Carter created the project
  2. Sofia Nguyen invited 3 members
  3. Brand guidelines.pdf was uploaded
  4. Ben Carter moved the project to review
"use client";

import {
  Timeline,
  TimelineDescription,
  TimelineItem,
  TimelineTitle,
} from "@/siteplane/components/timeline";

const activity = [
  {
    title: "Ben Carter created the project",
    dateTime: "2026-09-14T09:12",
    time: "Sep 14, 09:12",
  },
  {
    title: "Sofia Nguyen invited 3 members",
    dateTime: "2026-09-14T10:05",
    time: "Sep 14, 10:05",
  },
  {
    title: "Brand guidelines.pdf was uploaded",
    dateTime: "2026-09-15T14:30",
    time: "Sep 15, 14:30",
  },
  {
    title: "Ben Carter moved the project to review",
    dateTime: "2026-09-16T08:45",
    time: "Sep 16, 08:45",
  },
];

export default function Example() {
  return (
    <Timeline>
      {activity.map((event) => (
        <TimelineItem key={event.dateTime}>
          <TimelineTitle>{event.title}</TimelineTitle>
          <TimelineDescription>
            <time dateTime={event.dateTime}>{event.time}</time>
          </TimelineDescription>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/timeline

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/button

Usage

import {
  Timeline,
  TimelineItem,
  TimelineTitle,
  TimelineDescription,
} from "@/siteplane/components/timeline";

<Timeline>
  <TimelineItem>
    <TimelineTitle>Project created</TimelineTitle>
    <TimelineDescription>Sep 14, 09:12</TimelineDescription>
  </TimelineItem>
</Timeline>;

Composition

Timeline
├── TimelineItem
│   ├── TimelineTitle
│   └── TimelineDescription
└── TimelineNow (optional)

Status colors

Set tone on a TimelineItem to color its marker: neutral (the default), accent, info, success, warning or error. Include status in text; color alone should not carry the event meaning.

  1. Release 2.4.0 created
  2. Deployment queued
  3. Build started
  4. Checks passed
  5. Approval requested for production
  6. Deployment to production failed
"use client";

import {
  Timeline,
  TimelineDescription,
  TimelineItem,
  TimelineTitle,
} from "@/siteplane/components/timeline";

const deployment = [
  {
    tone: "accent",
    title: "Release 2.4.0 created",
    dateTime: "2026-09-16T09:00",
    time: "09:00",
  },
  {
    tone: "neutral",
    title: "Deployment queued",
    dateTime: "2026-09-16T09:01",
    time: "09:01",
  },
  {
    tone: "info",
    title: "Build started",
    dateTime: "2026-09-16T09:03",
    time: "09:03",
  },
  {
    tone: "success",
    title: "Checks passed",
    dateTime: "2026-09-16T09:11",
    time: "09:11",
  },
  {
    tone: "warning",
    title: "Approval requested for production",
    dateTime: "2026-09-16T09:12",
    time: "09:12",
  },
  {
    tone: "error",
    title: "Deployment to production failed",
    dateTime: "2026-09-16T09:40",
    time: "09:40",
  },
] as const;

export default function Example() {
  return (
    <Timeline>
      {deployment.map((event) => (
        <TimelineItem key={event.dateTime} tone={event.tone}>
          <TimelineTitle>{event.title}</TimelineTitle>
          <TimelineDescription>
            <time dateTime={event.dateTime}>{event.time}</time>
          </TimelineDescription>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Custom markers

Pass an icon as marker for recognizable event types. Without markerLabel the marker is decorative; add a label when the marker conveys something the text does not.

  1. Draft submitted for review
  2. Sofia Nguyen commented on the layout
  3. Design review by Ben Carter
"use client";

import { CheckIcon, FileTextIcon, MessageSquareIcon } from "lucide-react";
import {
  Timeline,
  TimelineDescription,
  TimelineItem,
  TimelineTitle,
} from "@/siteplane/components/timeline";

export default function Example() {
  return (
    <Timeline>
      <TimelineItem marker={<FileTextIcon aria-hidden="true" />} tone="info">
        <TimelineTitle>Draft submitted for review</TimelineTitle>
        <TimelineDescription>
          <time dateTime="2026-09-15T11:20">Sep 15, 11:20</time>
        </TimelineDescription>
      </TimelineItem>
      <TimelineItem
        marker={<MessageSquareIcon aria-hidden="true" />}
        tone="accent"
      >
        <TimelineTitle>Sofia Nguyen commented on the layout</TimelineTitle>
        <TimelineDescription>
          <time dateTime="2026-09-15T15:05">Sep 15, 15:05</time>
        </TimelineDescription>
      </TimelineItem>
      <TimelineItem
        marker={<CheckIcon aria-hidden="true" />}
        markerLabel="Approved"
        tone="success"
      >
        <TimelineTitle>Design review by Ben Carter</TimelineTitle>
        <TimelineDescription>
          <time dateTime="2026-09-16T10:30">Sep 16, 10:30</time>
        </TimelineDescription>
      </TimelineItem>
    </Timeline>
  );
}

Now marker

Place TimelineNow between two events to mark the present, such as the next appointment of the day. It is a row of the timeline with the accent dot of an event, the label "Now" in the accent and a line in the accent across the rest of the row. Pass label to show the word in the page's language, or to add the time, as in label="Now, 10:42 AM". The marker is a list item with aria-current="time", so assistive technologies read it in its place among the events. Give it a stable key, as the events have: when the present moves past an event, the marker glides to its new place.

  1. Haircut with Mara Ellis
  2. Coloring with Ben Carter
  3. Now
  4. Beard trim with Sofia Nguyen
  5. Consultation with Jonas Weber
"use client";

import {
  Timeline,
  TimelineDescription,
  TimelineItem,
  TimelineNow,
  TimelineTitle,
} from "@/siteplane/components/timeline";

const appointments = [
  {
    customer: "Mara Ellis",
    done: true,
    service: "Haircut",
    start: { dateTime: "2026-10-15T09:00", time: "9:00 AM" },
  },
  {
    customer: "Ben Carter",
    done: true,
    service: "Coloring",
    start: { dateTime: "2026-10-15T10:00", time: "10:00 AM" },
  },
  {
    customer: "Sofia Nguyen",
    done: false,
    service: "Beard trim",
    start: { dateTime: "2026-10-15T11:30", time: "11:30 AM" },
  },
  {
    customer: "Jonas Weber",
    done: false,
    service: "Consultation",
    start: { dateTime: "2026-10-15T13:30", time: "1:30 PM" },
  },
];

export default function Example() {
  const next = appointments.findIndex((appointment) => !appointment.done);

  return (
    <Timeline aria-label="Appointments today" className="w-full max-w-md">
      {appointments.flatMap((appointment, index) => {
        const event = (
          <TimelineItem
            key={appointment.start.dateTime}
            tone={appointment.done ? "success" : "neutral"}
          >
            <TimelineTitle>
              {appointment.service} with {appointment.customer}
            </TimelineTitle>
            <TimelineDescription>
              <time dateTime={appointment.start.dateTime}>
                {appointment.start.time}
              </time>
            </TimelineDescription>
          </TimelineItem>
        );
        return index === next ? [<TimelineNow key="now" />, event] : [event];
      })}
    </Timeline>
  );
}

Adding and removing events

Events that are added, removed or reordered animate on their own. A new event expands and fades in, and a removed one fades out and collapses while the events around it glide to their new place. The connector and the space to the next event open and close with the event after it, so the timeline never ends in a stub. Give every TimelineItem a stable key, such as an id, and never the index, because the key tells the timeline which event has left.

  1. Build started
    09:03
  2. Checks passed
    09:06
  3. Approval requested for production
    09:09
"use client";

import { PlusIcon } from "lucide-react";
import { useRef, useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  Timeline,
  TimelineDescription,
  TimelineItem,
  TimelineTitle,
} from "@/siteplane/components/timeline";

const pool = [
  { title: "Build started", tone: "info" },
  { title: "Checks passed", tone: "success" },
  { title: "Approval requested for production", tone: "warning" },
  { title: "Deployment to production failed", tone: "error" },
  { title: "Release 2.4.0 created", tone: "accent" },
] as const;

function eventAt(id: number) {
  const minutes = 9 * 60 + id * 3;
  const time = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
  return { ...pool[(id - 1) % pool.length]!, id, time };
}

export default function Example() {
  const [events, setEvents] = useState(() => [
    eventAt(1),
    eventAt(2),
    eventAt(3),
  ]);
  const next = useRef(4);
  const addButton = useRef<HTMLButtonElement>(null);
  const removeButtons = useRef(new Map<number, HTMLButtonElement>());

  function add(top: boolean) {
    const event = eventAt(next.current++);
    setEvents((current) => (top ? [event, ...current] : [...current, event]));
  }

  function remove(id: number) {
    // A leaving event is inert, so focus moves to the next event, the
    // previous one or Add event before this one leaves.
    const index = events.findIndex((event) => event.id === id);
    const neighbor = events[index + 1] ?? events[index - 1];
    const target = neighbor
      ? removeButtons.current.get(neighbor.id)
      : addButton.current;
    target?.focus();
    setEvents((current) => current.filter((event) => event.id !== id));
  }

  return (
    <div className="grid w-full max-w-md gap-6">
      <div className="flex flex-wrap gap-2">
        <Button onClick={() => add(false)} ref={addButton} size="sm">
          <PlusIcon /> Add event
        </Button>
        <Button onClick={() => add(true)} size="sm" variant="outline">
          Add to the top
        </Button>
      </div>
      <Timeline aria-label="Deployment history">
        {events.map((event) => (
          <TimelineItem key={event.id} tone={event.tone}>
            <TimelineTitle>{event.title}</TimelineTitle>
            <TimelineDescription className="flex items-center gap-2">
              {event.time}
              <Button
                aria-label={`Remove ${event.title} at ${event.time}`}
                onClick={() => remove(event.id)}
                profile="inline"
                profileTone="destructive"
                ref={(node) => {
                  if (node) removeButtons.current.set(event.id, node);
                  else removeButtons.current.delete(event.id);
                }}
                variant="link"
              >
                Remove
              </Button>
            </TimelineDescription>
          </TimelineItem>
        ))}
      </Timeline>
    </div>
  );
}

API reference

Component properties
PropTypeDefaultDescription
TimelineItem.toneneutral | accent | info | success | warning | errorneutralMarker color, with at least 3:1 contrast against the page in light and dark themes.
TimelineItem.variantdefault | accent | info | success | warning | errorunsetDeprecated: the earlier name of tone, where default is neutral. tone wins when both are set.
TimelineItem.markerShapeTimelineMarkerShape: bar | diamond | dot | ringdotMarker geometry; ring draws the tone color as an outline.
TimelineItem.markerReactNodeunsetCustom marker content, such as an icon, in a larger marker. Icons use a color that contrasts with the marker.
TimelineItem.markerLabelstringunsetAccessible marker label; unlabeled markers are hidden from assistive technology.
TimelineNow.labelstring"Now"Text of the now marker beside its dot, read by assistive technology; pass the word in the page's language.

Timeline renders ol with role list. TimelineItemProps extends native li props without the four animation handlers Motion owns (onAnimationStart, onDrag, onDragStart and onDragEnd); TimelineItem must be rendered inside Timeline. TimelineNowProps takes the same li props without children and the marker props; TimelineNow renders an li with aria-current="time" and must be rendered inside Timeline too. TimelineTitle and TimelineDescription accept native div props. All parts accept refs, children and string className. TimelineTone and TimelineMarkerShape are exported; TimelineItemVariant is the deprecated type of variant. There is no sorting, pagination or date parsing; the application supplies ordered events and timestamps, and added or removed events animate on their own. Durations and easings come from the shared motion tokens, and every change is instant when the system asks for reduced motion, when html carries data-motion-reduce="true", or inside SiteplaneMotionProvider preset="reduced".

Read the installed source.

Usage notes

Use native time elements with machine-readable dateTime values where timestamps matter. Empty/loading/error behavior for an event feed belongs to the application.

Events must be direct children of Timeline, not wrapped in a fragment: a fragment around several events counts as one. A leaving event is inert and hidden from assistive technology while it fades, so focus inside it is lost when it leaves. Move focus to a neighboring event before removing one, as the example does.