# Timeline

> Events on a tube that is lit up to now.

Category: Data Display. Docs: https://neonkit.heyaadi.com/components/timeline

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/timeline.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/timeline`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Timeline, TimelineItem } from "@/components/ui/timeline"

<Timeline>
  <TimelineItem title="Identified" time="09:31 UTC" dateTime="2026-10-11T09:31Z">
    A connection pool limit starves requests under load.
  </TimelineItem>
  <TimelineItem status="current" title="Monitoring" time="10:05 UTC">
    Error rates are back under 0.1%.
  </TimelineItem>
  <TimelineItem status="upcoming" title="Resolved" />
</Timeline>
```

## Examples

### Incident updates

The tube is lit up to the current item and unlit glass after it. On mount the light travels down from the first marker to now.

```tsx
import { Badge } from "@/components/ui/badge";
import { Timeline, TimelineItem } from "@/components/ui/timeline";

export default function TimelineDemo() {
  return (
    <div className="w-full max-w-md rounded-2xl border border-white/10 bg-white/[0.03] p-6">
      <div className="mb-6 flex items-start justify-between gap-4">
        <div className="grid gap-1">
          <h3 className="text-base font-semibold text-white/90">Elevated error rates on the EU API</h3>
          <p className="text-xs text-white/50">Affects api.eu · fra1, lhr1</p>
        </div>
        <Badge dot pulse>
          Monitoring
        </Badge>
      </div>

      <Timeline>
        <TimelineItem title="Investigating" time="09:12 UTC" dateTime="2026-10-11T09:12Z">
          Error rates rose to 4.2% minutes after the 09:05 deploy. We&apos;re looking into it.
        </TimelineItem>
        <TimelineItem title="Identified" time="09:31 UTC" dateTime="2026-10-11T09:31Z">
          A lower connection pool limit in v4.19.0 starves requests under peak load.
        </TimelineItem>
        <TimelineItem title="Fix deployed" time="09:48 UTC" dateTime="2026-10-11T09:48Z">
          Rolled back to v4.18.2 in fra1 and lhr1.
        </TimelineItem>
        <TimelineItem status="current" title="Monitoring" time="10:05 UTC" dateTime="2026-10-11T10:05Z">
          Error rates are back under 0.1%. We&apos;ll keep watching for the next hour.
        </TimelineItem>
        <TimelineItem status="upcoming" title="Resolved" />
      </Timeline>
    </div>
  );
}
```

### With icons

An icon replaces the dot in the marker. Durations use an ISO 8601 duration as their dateTime.

```tsx
import { GitCommitHorizontal, Globe, Hammer, Rocket, ShieldCheck } from "@/components/ui/icons";
import { Timeline, TimelineItem } from "@/components/ui/timeline";

export default function TimelineDeployPipeline() {
  return (
    <div className="w-full max-w-md">
      <Timeline>
        <TimelineItem
          icon={<GitCommitHorizontal />}
          title="Commit pushed to main"
          time="14:02"
          dateTime="2026-10-11T14:02Z"
        >
          <span className="font-mono text-xs text-white/50">8f3a2c1</span> · Cache edge config between deploys
        </TimelineItem>
        <TimelineItem icon={<Hammer />} title="Built" time="48s" dateTime="PT48S">
          312 routes, 18 functions. Restored 1.4 GB from the build cache.
        </TimelineItem>
        <TimelineItem icon={<ShieldCheck />} title="Checks passed" time="1m 06s" dateTime="PT1M6S">
          Type check, 1,284 tests and the Lighthouse budget.
        </TimelineItem>
        <TimelineItem status="current" icon={<Rocket />} title="Deploying to fra1, iad1, hnd1" time="Running">
          2 of 3 regions are live. Waiting on health checks in hnd1.
        </TimelineItem>
        <TimelineItem status="upcoming" icon={<Globe />} title="Promote to production">
          Swaps neonkit.dev to this deployment.
        </TimelineItem>
      </Timeline>
    </div>
  );
}
```

### Horizontal

ISO date strings are formatted for you and rendered as <time>. Each item is at least 10rem wide, so put long timelines in a horizontal ScrollArea.

```tsx
import { ScrollArea } from "@/components/ui/scroll-area";
import { Timeline, TimelineItem } from "@/components/ui/timeline";

export default function TimelineRoadmap() {
  return (
    <ScrollArea orientation="horizontal" aria-label="Release roadmap" className="w-full max-w-[44rem]">
      <Timeline orientation="horizontal" className="min-w-[43rem] px-1 pb-5 pt-3">
        <TimelineItem className="min-w-[8.5rem]" title="v2.0" time="2026-03-04">
          Tailwind 4 support and the new color system.
        </TimelineItem>
        <TimelineItem className="min-w-[8.5rem]" title="v2.1" time="2026-05-20">
          Select, Combobox and form field anatomy.
        </TimelineItem>
        <TimelineItem className="min-w-[8.5rem]" title="v2.2" time="2026-08-12">
          Overlays move to floating-ui.
        </TimelineItem>
        <TimelineItem className="min-w-[8.5rem]" status="current" title="v2.3 release candidate" time="2026-10-08">
          Table, Timeline and Scroll Area. In testing now.
        </TimelineItem>
        <TimelineItem className="min-w-[8.5rem]" status="upcoming" title="v2.4" time="Q1 2027">
          Charts and a data grid.
        </TimelineItem>
      </Timeline>
    </ScrollArea>
  );
}
```

### Advancing

When an item moves to current, the segment before it fills and its marker strikes like a tube.

```tsx
"use client";

import * as React from "react";
import { RotateCcw } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Timeline, TimelineItem, type TimelineStatus } from "@/components/ui/timeline";

const events = [
  { title: "Order placed", time: "Oct 9, 10:24", detail: "Order #48213 · 2 items" },
  { title: "Packed", time: "Oct 9, 16:02", detail: "Left the Rotterdam fulfilment center." },
  { title: "In transit", time: "Oct 10, 07:45", detail: "Arrived at the Antwerp sorting hub." },
  { title: "Out for delivery", time: "Oct 11, 08:10", detail: "With your courier. Usually arrives by 13:00." },
  { title: "Delivered", time: "Oct 11, 11:38", detail: "Left with the building reception." },
];

function statusOf(index: number, current: number): TimelineStatus {
  if (index < current) return "done";
  if (index === current) return current === events.length - 1 ? "done" : "current";
  return "upcoming";
}

export default function TimelineLive() {
  const [current, setCurrent] = React.useState(2);
  const delivered = current === events.length - 1;

  return (
    <div className="grid w-full max-w-sm gap-6">
      <Timeline>
        {events.map((event, index) => {
          const status = statusOf(index, current);
          return (
            <TimelineItem
              key={event.title}
              status={status}
              title={event.title}
              time={status === "upcoming" ? undefined : event.time}
            >
              {status === "upcoming" ? null : event.detail}
            </TimelineItem>
          );
        })}
      </Timeline>
      <div className="flex gap-2">
        <Button
          size="sm"
          variant="soft"
          disabled={delivered}
          onClick={() => setCurrent((step) => Math.min(step + 1, events.length - 1))}
        >
          Next tracking update
        </Button>
        <Button size="sm" variant="ghost" onClick={() => setCurrent(0)}>
          <RotateCcw /> Reset
        </Button>
      </div>
    </div>
  );
}
```

## API reference

### Timeline

Renders an <ol>. Also accepts native list attributes. Pass TimelineItem elements as direct children.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"vertical" \| "horizontal"` | `"vertical"` | Vertical runs the tube down the left. Horizontal runs it along the top with content below. |
| `formatTime` | `(date: Date) => ReactNode` | — | Formats a Date or ISO string time. The default is a short en-US format in UTC, so server and client render the same text. |
| `statusLabels` | `Partial<Record<"done" \| "upcoming", string>>` | `{ done: "Completed", upcoming: "Upcoming" }` | Visually hidden words read before each title. Translate them here. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### TimelineItem

Renders an <li>. Children become the description and may contain any block content.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `ReactNode` | — | The event name. |
| `status` | `"done" \| "current" \| "upcoming"` | `"done"` | Done and current items light the tube. The current marker pulses softly and gets aria-current="step". |
| `time` | `ReactNode \| Date` | — | A Date or ISO string renders a formatted <time dateTime>. Anything else renders as given. |
| `dateTime` | `string` | — | Machine-readable value for a human time such as "2 hours ago". Renders a <time>. |
| `icon` | `ReactNode` | — | Replaces the dot inside the marker. |

## Accessibility

- It's an ordered list, so screen readers announce the number of events and each one's position.
- Status isn't conveyed by light alone: done and upcoming items carry visually hidden labels, and the current item has aria-current="step".
- Times with a machine-readable value render as <time dateTime>, so assistive tech and parsers get the exact moment.
- With reduced motion the tube doesn't fill on mount and the current marker holds a steady glow instead of pulsing.

## Source

`components/ui/timeline.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

export type TimelineStatus = "done" | "current" | "upcoming";
type Orientation = "vertical" | "horizontal";

/** Milliseconds the light takes to travel from one marker to the next on mount. */
const STEP = 130;

// The tube powers on once from the first marker to "now". After that, a newly
// lit segment fills on its own and a new current marker strikes like a tube.
const keyframes = `
@keyframes nk-timeline-fill-y { from { transform: scaleY(0); } }
@keyframes nk-timeline-fill-x { from { transform: scaleX(0); } }
@keyframes nk-timeline-on { from { opacity: 0.3; } }
@keyframes nk-timeline-fade { from { opacity: 0; } }
@keyframes nk-timeline-ignite { 0% { opacity: 0.3; } 12% { opacity: 0.9; } 22% { opacity: 0.35; } 40% { opacity: 1; } 52% { opacity: 0.5; } 68%, 100% { opacity: 1; } }
@keyframes nk-timeline-pulse { 0%, 100% { opacity: 0.4; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.15); } }
`;

// The current item is announced through aria-current instead.
type LabelledStatus = Exclude<TimelineStatus, "current">;
const defaultStatusLabels: Record<LabelledStatus, string> = {
  done: "Completed",
  upcoming: "Upcoming",
};

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/;

// UTC keeps the server and client render identical. Pass formatTime for local time.
const dateTimeFormat = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
  timeZoneName: "short",
});
const dateFormat = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  year: "numeric",
  timeZone: "UTC",
});

type TimelineContextValue = {
  orientation: Orientation;
  formatTime?: (date: Date) => React.ReactNode;
  statusLabels: Record<LabelledStatus, string>;
  /** False during the mount sequence, while delays are staggered. */
  settled: boolean;
};

type ItemPosition = { index: number; last: boolean; next?: TimelineStatus };

const TimelineContext = React.createContext<TimelineContextValue | null>(null);
const ItemPositionContext = React.createContext<ItemPosition>({ index: 0, last: true });

function useTimeline() {
  const context = React.useContext(TimelineContext);
  if (!context) throw new Error("<TimelineItem> must be used inside <Timeline>.");
  return context;
}

export interface TimelineProps extends Omit<React.OlHTMLAttributes<HTMLOListElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  orientation?: Orientation;
  /** Formats `time` when it is a Date or ISO string. Defaults to a short UTC format. */
  formatTime?: (date: Date) => React.ReactNode;
  /** Visually hidden status words read before done and upcoming titles. */
  statusLabels?: Partial<Record<LabelledStatus, string>>;
}

const Timeline = React.forwardRef<HTMLOListElement, TimelineProps>(
  (
    {
      className,
      style,
      color,
      orientation = "vertical",
      formatTime,
      statusLabels,
      children,
      ...props
    },
    ref,
  ) => {
    const items = React.Children.toArray(children).filter(React.isValidElement);
    const statuses = items.map(
      (child) => (child.props as { status?: TimelineStatus }).status ?? "done",
    );

    const [settled, setSettled] = React.useState(false);
    const settleAfter = (items.length + 4) * STEP;
    React.useEffect(() => {
      const timer = window.setTimeout(() => setSettled(true), settleAfter);
      return () => window.clearTimeout(timer);
    }, [settleAfter]);

    const context = React.useMemo<TimelineContextValue>(
      () => ({
        orientation,
        formatTime,
        statusLabels: { ...defaultStatusLabels, ...statusLabels },
        settled,
      }),
      [orientation, formatTime, statusLabels, settled],
    );

    return (
      <TimelineContext.Provider value={context}>
        <ol
          ref={ref}
          data-orientation={orientation}
          style={neonVars(color, style)}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "m-0 list-none p-0",
            orientation === "horizontal" ? "flex w-full" : "flex flex-col",
            className,
          )}
          {...props}
        >
          <style>{keyframes}</style>
          {items.map((child, index) => (
            <ItemPositionContext.Provider
              key={child.key ?? index}
              value={{ index, last: index === items.length - 1, next: statuses[index + 1] }}
            >
              {child}
            </ItemPositionContext.Provider>
          ))}
        </ol>
      </TimelineContext.Provider>
    );
  },
);
Timeline.displayName = "Timeline";

function TimeLabel({
  time,
  dateTime,
  formatTime,
  className,
}: {
  time: React.ReactNode | Date;
  dateTime?: string;
  formatTime?: (date: Date) => React.ReactNode;
  className?: string;
}) {
  let date: Date | null = null;
  let dateOnly = false;
  if (time instanceof Date) date = time;
  else if (typeof time === "string" && (ISO_DATE.test(time) || ISO_DATE_TIME.test(time))) {
    dateOnly = ISO_DATE.test(time);
    date = new Date(time);
  }

  if (date) {
    if (Number.isNaN(date.getTime())) return null;
    const machine = typeof time === "string" ? time : date.toISOString();
    const label = formatTime
      ? formatTime(date)
      : (dateOnly ? dateFormat : dateTimeFormat).format(date);
    return (
      <time dateTime={dateTime ?? machine} className={className}>
        {label}
      </time>
    );
  }

  if (time == null || time === false || time === "") return null;
  if (dateTime) {
    return (
      <time dateTime={dateTime} className={className}>
        {time as React.ReactNode}
      </time>
    );
  }
  return <span className={className}>{time as React.ReactNode}</span>;
}

export interface TimelineItemProps
  extends Omit<React.LiHTMLAttributes<HTMLLIElement>, "title"> {
  /** Done and current items light the tube; upcoming items stay unlit glass. */
  status?: TimelineStatus;
  /** A Date or ISO string renders a `<time>`; anything else renders as given. */
  time?: React.ReactNode | Date;
  /** Machine-readable value for a human `time` such as "2 hours ago". */
  dateTime?: string;
  title: React.ReactNode;
  /** Replaces the dot inside the marker. */
  icon?: React.ReactNode;
}

const TimelineItem = React.forwardRef<HTMLLIElement, TimelineItemProps>(
  (
    { className, status = "done", time, dateTime, title, icon, children, ...props },
    ref,
  ) => {
    const { orientation, formatTime, statusLabels, settled } = useTimeline();
    const { index, last, next } = React.useContext(ItemPositionContext);
    const vertical = orientation === "vertical";

    // Lit up to and including the current item. Past "now" the light bleeds a
    // little way into the glass before it dies out.
    const segment =
      status === "done" && next !== "upcoming"
        ? "lit"
        : status !== "upcoming"
          ? "bleed"
          : "unlit";

    const delay = settled ? 0 : index * STEP;
    const showTime = time != null && time !== false && time !== "";

    const marker = (
      <span aria-hidden className="relative size-6 shrink-0">
        <span
          style={{ animationDelay: `${delay}ms` }}
          className={cn(
            "absolute inset-0",
            status === "current" &&
              "[animation:nk-timeline-ignite_380ms_linear_backwards] motion-reduce:[animation:none]",
            status === "done" &&
              !settled &&
              "[animation:nk-timeline-on_220ms_ease-out_backwards] motion-reduce:[animation:none]",
          )}
        >
          {status === "current" && (
            <span className="absolute -inset-2.5 rounded-full bg-[radial-gradient(closest-side,rgb(var(--neon)/CALC(0.5*var(--neon-glow,1))),transparent)] opacity-70 [animation:nk-timeline-pulse_2.4s_ease-in-out_infinite] motion-reduce:[animation:none]" />
          )}
          <span
            className={cn(
              "absolute inset-0 grid place-items-center rounded-full border",
              "transition-[background-color,border-color,box-shadow,color] duration-300 ease-out",
              // Icons sit a touch heavier than text strokes so they read at this small size.
              "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:[stroke-width:2]",
              status === "done" &&
                "border-[rgb(var(--neon)/0.45)] bg-[rgb(var(--neon)/0.1)] text-[rgb(var(--neon))]",
              status === "current" &&
                "border-[rgb(var(--neon))] bg-[rgb(var(--neon)/0.16)] text-[rgb(var(--neon))] shadow-[0_0_0_3px_rgb(var(--neon)/0.1),0_0_16px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1))),inset_0_0_8px_rgb(var(--neon)/CALC(0.3*var(--neon-glow,1)))] [&_svg]:[filter:drop-shadow(0_0_4px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1))))]",
              status === "upcoming" && "border-white/10 bg-white/[0.03] text-white/35",
            )}
          >
            {icon ?? (
              <span
                className={cn(
                  "rounded-full",
                  status === "done" && "size-1.5 bg-current shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1)))]",
                  status === "current" &&
                    "size-2 bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon)/var(--neon-glow,1))]",
                  status === "upcoming" && "size-1.5 border border-white/25",
                )}
              />
            )}
          </span>
        </span>
      </span>
    );

    const tube = !last && (
      <span
        aria-hidden
        data-state={segment}
        className={cn(
          "relative flex-1 bg-white/[0.035]",
          vertical
            ? "w-1.5 min-h-4 shadow-[inset_1px_0_0_rgb(255_255_255/0.07),inset_-1px_0_0_rgb(255_255_255/0.07)]"
            : "h-1.5 min-w-4 shadow-[inset_0_1px_0_rgb(255_255_255/0.07),inset_0_-1px_0_rgb(255_255_255/0.07)]",
        )}
      >
        {segment === "lit" && (
          // Tinted glass and the bright core inside it fill together.
          <span
            style={{ animationDelay: `${delay}ms` }}
            className={cn(
              "absolute inset-0 bg-[rgb(var(--neon)/0.14)] motion-reduce:[animation:none]",
              vertical
                ? "origin-top shadow-[inset_1px_0_0_rgb(var(--neon)/0.35),inset_-1px_0_0_rgb(var(--neon)/0.35)]"
                : "origin-left shadow-[inset_0_1px_0_rgb(var(--neon)/0.35),inset_0_-1px_0_rgb(var(--neon)/0.35)]",
              settled
                ? vertical
                  ? "[animation:nk-timeline-fill-y_420ms_cubic-bezier(0.65,0,0.35,1)_backwards]"
                  : "[animation:nk-timeline-fill-x_420ms_cubic-bezier(0.65,0,0.35,1)_backwards]"
                : vertical
                  ? "[animation:nk-timeline-fill-y_130ms_linear_backwards]"
                  : "[animation:nk-timeline-fill-x_130ms_linear_backwards]",
            )}
          >
            <span
              className={cn(
                "absolute bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1))),0_0_14px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]",
                vertical ? "inset-x-[2px] inset-y-0" : "inset-x-0 inset-y-[2px]",
              )}
            />
          </span>
        )}
        {segment === "bleed" && (
          <span
            style={{ animationDelay: `${settled ? 0 : delay + STEP}ms` }}
            className={cn(
              "absolute opacity-80 [animation:nk-timeline-fade_400ms_ease-out_backwards] motion-reduce:[animation:none]",
              vertical
                ? "inset-x-[2px] top-0 h-5 bg-[linear-gradient(to_bottom,rgb(var(--neon)),transparent)]"
                : "inset-y-[2px] left-0 w-5 bg-[linear-gradient(to_right,rgb(var(--neon)),transparent)]",
            )}
          />
        )}
      </span>
    );

    const timeClass = cn(
      "text-xs tabular-nums",
      status === "current" ? "text-[rgb(var(--neon)/0.9)]" : "text-white/55",
    );

    const heading = (
      <div
        className={cn(
          "text-sm font-medium leading-5",
          status === "upcoming" ? "text-white/55" : "text-white/90",
        )}
      >
        {status !== "current" && <span className="sr-only">{statusLabels[status]}: </span>}
        {title}
      </div>
    );

    const description = children != null && children !== false && (
      <div className="mt-1 text-sm leading-6 text-white/55">{children}</div>
    );

    return (
      <li
        ref={ref}
        data-status={status}
        aria-current={status === "current" ? "step" : undefined}
        className={cn(
          "relative flex",
          vertical ? "gap-4" : "min-w-[10rem] flex-1 flex-col",
          className,
        )}
        {...props}
      >
        {vertical ? (
          <>
            <div className="flex w-6 shrink-0 flex-col items-center">
              {marker}
              {tube}
            </div>
            <div className={cn("min-w-0 flex-1 pt-0.5", !last && "pb-8")}>
              <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
                {heading}
                {showTime && (
                  <TimeLabel time={time} dateTime={dateTime} formatTime={formatTime} className={timeClass} />
                )}
              </div>
              {description}
            </div>
          </>
        ) : (
          <>
            <div className="flex h-6 items-center">
              {marker}
              {tube}
            </div>
            <div className="min-w-0 pr-6 pt-3">
              {showTime && (
                <TimeLabel
                  time={time}
                  dateTime={dateTime}
                  formatTime={formatTime}
                  className={cn("mb-1 block", timeClass)}
                />
              )}
              {heading}
              {description}
            </div>
          </>
        )}
      </li>
    );
  },
);
TimelineItem.displayName = "TimelineItem";

export { Timeline, TimelineItem };
```
