Dice UI
Components

Timeline

A flexible timeline component for displaying chronological events with support for different orientations, RTL layouts, and visual states.

API
import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineDot,
  TimelineHeader,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";
 
const timelineItems = [
  {
    id: "project-kickoff",
    dateTime: "2025-01-15",
    date: "January 15, 2025",
    title: "Project Kickoff",
    description: "Initial meeting to define scope.",
  },
  {
    id: "design-phase",
    dateTime: "2025-02-01",
    date: "February 1, 2025",
    title: "Design Phase",
    description: "Created wireframes and mockups.",
  },
  {
    id: "development",
    dateTime: "2025-03-01",
    date: "March 1, 2025",
    title: "Development",
    description: "Building core features.",
  },
];
 
export function TimelineDemo() {
  return (
    <Timeline activeIndex={1}>
      {timelineItems.map((item) => (
        <TimelineItem key={item.id}>
          <TimelineDot />
          <TimelineConnector />
          <TimelineContent>
            <TimelineHeader>
              <TimelineTime dateTime={item.dateTime}>{item.date}</TimelineTime>
              <TimelineTitle>{item.title}</TimelineTitle>
            </TimelineHeader>
            <TimelineDescription>{item.description}</TimelineDescription>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/timeline"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot class-variance-authority

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
 
type Direction = "ltr" | "rtl";
type Orientation = "vertical" | "horizontal";
type Variant = "default" | "alternate";
type Status = "completed" | "active" | "pending";
 
interface DivProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
type ItemElement = React.ComponentRef<typeof TimelineItem>;
 
const ROOT_NAME = "Timeline";
const ITEM_NAME = "TimelineItem";
const DOT_NAME = "TimelineDot";
const CONNECTOR_NAME = "TimelineConnector";
const CONTENT_NAME = "TimelineContent";
 
const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
 
function useLazyRef<T>(fn: () => T) {
  const ref = React.useRef<T | null>(null);
 
  if (ref.current === null) {
    ref.current = fn();
  }
 
  return ref as React.RefObject<T>;
}
 
function getItemStatus(itemIndex: number, activeIndex?: number): Status {
  if (activeIndex === undefined) return "pending";
  if (itemIndex < activeIndex) return "completed";
  if (itemIndex === activeIndex) return "active";
  return "pending";
}
 
function getSortedEntries(
  entries: [string, React.RefObject<ItemElement | null>][],
) {
  return entries.sort((a, b) => {
    const elementA = a[1].current;
    const elementB = b[1].current;
    if (!elementA || !elementB) return 0;
    const position = elementA.compareDocumentPosition(elementB);
    if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
    if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
    return 0;
  });
}
 
function useStore<T>(selector: (store: Store) => T): T {
  const store = React.useContext(StoreContext);
  if (!store) {
    throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
  }
 
  const getSnapshot = React.useCallback(
    () => selector(store),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
const DirectionContext = React.createContext<Direction | undefined>(undefined);
 
function useDirection(dirProp?: Direction): Direction {
  const contextDir = React.useContext(DirectionContext);
  return dirProp ?? contextDir ?? "ltr";
}
 
interface StoreState {
  items: Map<string, React.RefObject<ItemElement | null>>;
}
 
interface Store {
  subscribe: (callback: () => void) => () => void;
  getState: () => StoreState;
  notify: () => void;
  onItemRegister: (
    id: string,
    ref: React.RefObject<ItemElement | null>,
  ) => void;
  onItemUnregister: (id: string) => void;
  getNextItemStatus: (id: string, activeIndex?: number) => Status | undefined;
  getItemIndex: (id: string) => number;
}
 
const StoreContext = React.createContext<Store | null>(null);
 
function useStoreContext(consumerName: string) {
  const context = React.useContext(StoreContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
interface TimelineContextValue {
  dir: Direction;
  orientation: Orientation;
  variant: Variant;
  activeIndex?: number;
}
 
const TimelineContext = React.createContext<TimelineContextValue | null>(null);
 
function useTimelineContext(consumerName: string) {
  const context = React.useContext(TimelineContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
const timelineVariants = cva(
  "relative flex [--timeline-connector-thickness:0.125rem] [--timeline-dot-size:0.875rem]",
  {
    variants: {
      orientation: {
        vertical: "flex-col",
        horizontal: "flex-row items-start",
      },
      variant: {
        default: "",
        alternate: "",
      },
    },
    compoundVariants: [
      {
        orientation: "vertical",
        variant: "default",
        class: "gap-6",
      },
      {
        orientation: "horizontal",
        variant: "default",
        class: "gap-8",
      },
      {
        orientation: "vertical",
        variant: "alternate",
        class: "relative w-full gap-3",
      },
      {
        orientation: "horizontal",
        variant: "alternate",
        class: "items-center gap-4",
      },
    ],
    defaultVariants: {
      orientation: "vertical",
      variant: "default",
    },
  },
);
 
interface TimelineRootProps extends DivProps {
  dir?: Direction;
  orientation?: Orientation;
  variant?: Variant;
  activeIndex?: number;
}
 
function TimelineRoot(props: TimelineRootProps) {
  const {
    orientation = "vertical",
    variant = "default",
    dir: dirProp,
    activeIndex,
    asChild,
    className,
    ...rootProps
  } = props;
 
  const dir = useDirection(dirProp);
 
  const listenersRef = useLazyRef(() => new Set<() => void>());
  const stateRef = useLazyRef<StoreState>(() => ({
    items: new Map(),
  }));
 
  const store = React.useMemo<Store>(() => {
    return {
      subscribe: (cb) => {
        listenersRef.current.add(cb);
        return () => listenersRef.current.delete(cb);
      },
      getState: () => stateRef.current,
      notify: () => {
        for (const cb of listenersRef.current) {
          cb();
        }
      },
      onItemRegister: (
        id: string,
        ref: React.RefObject<ItemElement | null>,
      ) => {
        stateRef.current.items.set(id, ref);
        store.notify();
      },
      onItemUnregister: (id: string) => {
        stateRef.current.items.delete(id);
        store.notify();
      },
      getNextItemStatus: (id: string, activeIndex?: number) => {
        const entries = Array.from(stateRef.current.items.entries());
        const sortedEntries = getSortedEntries(entries);
 
        const currentIndex = sortedEntries.findIndex(([key]) => key === id);
        if (currentIndex === -1 || currentIndex === sortedEntries.length - 1) {
          return undefined;
        }
 
        const nextItemIndex = currentIndex + 1;
        return getItemStatus(nextItemIndex, activeIndex);
      },
      getItemIndex: (id: string) => {
        const entries = Array.from(stateRef.current.items.entries());
        const sortedEntries = getSortedEntries(entries);
        return sortedEntries.findIndex(([key]) => key === id);
      },
    };
  }, [listenersRef, stateRef]);
 
  const contextValue = React.useMemo<TimelineContextValue>(
    () => ({
      dir,
      orientation,
      variant,
      activeIndex,
    }),
    [dir, orientation, variant, activeIndex],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <StoreContext.Provider value={store}>
      <TimelineContext.Provider value={contextValue}>
        <RootPrimitive
          role="list"
          aria-orientation={orientation}
          data-slot="timeline"
          data-orientation={orientation}
          data-variant={variant}
          dir={dir}
          {...rootProps}
          className={cn(timelineVariants({ orientation, variant, className }))}
        />
      </TimelineContext.Provider>
    </StoreContext.Provider>
  );
}
 
interface TimelineItemContextValue {
  id: string;
  status: Status;
  isAlternateRight: boolean;
}
 
const TimelineItemContext =
  React.createContext<TimelineItemContextValue | null>(null);
 
function useTimelineItemContext(consumerName: string) {
  const context = React.useContext(TimelineItemContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
  }
  return context;
}
 
const timelineItemVariants = cva("relative flex", {
  variants: {
    orientation: {
      vertical: "",
      horizontal: "",
    },
    variant: {
      default: "",
      alternate: "",
    },
    isAlternateRight: {
      true: "",
      false: "",
    },
  },
  compoundVariants: [
    {
      orientation: "vertical",
      variant: "default",
      class: "gap-3 pb-8 last:pb-0",
    },
    {
      orientation: "horizontal",
      variant: "default",
      class: "flex-col gap-3",
    },
    {
      orientation: "vertical",
      variant: "alternate",
      isAlternateRight: false,
      class: "w-1/2 gap-3 pr-6 pb-12 last:pb-0",
    },
    {
      orientation: "vertical",
      variant: "alternate",
      isAlternateRight: true,
      class: "ml-auto w-1/2 flex-row-reverse gap-3 pb-12 pl-6 last:pb-0",
    },
    {
      orientation: "horizontal",
      variant: "alternate",
      class: "grid min-w-0 grid-rows-[1fr_auto_1fr] gap-3",
    },
  ],
  defaultVariants: {
    orientation: "vertical",
    variant: "default",
    isAlternateRight: false,
  },
});
 
function TimelineItem(props: DivProps) {
  const { asChild, className, id, ref, ...itemProps } = props;
 
  const { dir, orientation, variant, activeIndex } =
    useTimelineContext(ITEM_NAME);
  const store = useStoreContext(ITEM_NAME);
 
  const instanceId = React.useId();
  const itemId = id ?? instanceId;
  const itemRef = React.useRef<ItemElement | null>(null);
  const composedRef = useComposedRefs(ref, itemRef);
 
  const itemIndex = useStore((state) => state.getItemIndex(itemId));
 
  const status = React.useMemo<Status>(() => {
    return getItemStatus(itemIndex, activeIndex);
  }, [activeIndex, itemIndex]);
 
  useIsomorphicLayoutEffect(() => {
    store.onItemRegister(itemId, itemRef);
    return () => {
      store.onItemUnregister(itemId);
    };
  }, [id, store]);
 
  const isAlternateRight = variant === "alternate" && itemIndex % 2 === 1;
 
  const itemContextValue = React.useMemo<TimelineItemContextValue>(
    () => ({ id: itemId, status, isAlternateRight }),
    [itemId, status, isAlternateRight],
  );
 
  const ItemPrimitive = asChild ? Slot : "div";
 
  return (
    <TimelineItemContext.Provider value={itemContextValue}>
      <ItemPrimitive
        role="listitem"
        aria-current={status === "active" ? "step" : undefined}
        data-slot="timeline-item"
        data-status={status}
        data-orientation={orientation}
        data-alternate-right={isAlternateRight ? "" : undefined}
        id={itemId}
        dir={dir}
        {...itemProps}
        ref={composedRef}
        className={cn(
          timelineItemVariants({
            orientation,
            variant,
            isAlternateRight,
            className,
          }),
        )}
      />
    </TimelineItemContext.Provider>
  );
}
 
const timelineContentVariants = cva("flex-1", {
  variants: {
    orientation: {
      vertical: "",
      horizontal: "",
    },
    variant: {
      default: "",
      alternate: "",
    },
    isAlternateRight: {
      true: "",
      false: "",
    },
  },
  compoundVariants: [
    {
      variant: "alternate",
      orientation: "vertical",
      isAlternateRight: false,
      class: "text-right",
    },
    {
      variant: "alternate",
      orientation: "horizontal",
      isAlternateRight: false,
      class: "row-start-3 pt-2",
    },
    {
      variant: "alternate",
      orientation: "horizontal",
      isAlternateRight: true,
      class: "row-start-1 pb-2",
    },
  ],
  defaultVariants: {
    orientation: "vertical",
    variant: "default",
    isAlternateRight: false,
  },
});
 
function TimelineContent(props: DivProps) {
  const { asChild, className, ...contentProps } = props;
 
  const { variant, orientation } = useTimelineContext(CONTENT_NAME);
  const { status, isAlternateRight } = useTimelineItemContext(CONTENT_NAME);
 
  const ContentPrimitive = asChild ? Slot : "div";
 
  return (
    <ContentPrimitive
      data-slot="timeline-content"
      data-status={status}
      {...contentProps}
      className={cn(
        timelineContentVariants({
          orientation,
          variant,
          isAlternateRight,
          className,
        }),
      )}
    />
  );
}
 
const timelineDotVariants = cva(
  "relative z-10 flex size-[var(--timeline-dot-size)] shrink-0 items-center justify-center rounded-full border-2 bg-background",
  {
    variants: {
      status: {
        completed: "border-primary",
        active: "border-primary",
        pending: "border-border",
      },
      orientation: {
        vertical: "",
        horizontal: "",
      },
      variant: {
        default: "",
        alternate: "",
      },
      isAlternateRight: {
        true: "",
        false: "",
      },
    },
    compoundVariants: [
      {
        variant: "alternate",
        orientation: "vertical",
        isAlternateRight: false,
        class:
          "-right-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] absolute bg-background",
      },
      {
        variant: "alternate",
        orientation: "vertical",
        isAlternateRight: true,
        class:
          "-left-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] absolute bg-background",
      },
      {
        variant: "alternate",
        orientation: "horizontal",
        class: "row-start-2 bg-background",
      },
      {
        variant: "alternate",
        status: "completed",
        class: "bg-background",
      },
      {
        variant: "alternate",
        status: "active",
        class: "bg-background",
      },
    ],
    defaultVariants: {
      status: "pending",
      orientation: "vertical",
      variant: "default",
      isAlternateRight: false,
    },
  },
);
 
function TimelineDot(props: DivProps) {
  const { asChild, className, ...dotProps } = props;
 
  const { orientation, variant } = useTimelineContext(DOT_NAME);
  const { status, isAlternateRight } = useTimelineItemContext(DOT_NAME);
 
  const DotPrimitive = asChild ? Slot : "div";
 
  return (
    <DotPrimitive
      data-slot="timeline-dot"
      data-status={status}
      data-orientation={orientation}
      {...dotProps}
      className={cn(
        timelineDotVariants({
          status,
          orientation,
          variant,
          isAlternateRight,
          className,
        }),
      )}
    />
  );
}
 
const timelineConnectorVariants = cva("absolute z-0", {
  variants: {
    isCompleted: {
      true: "bg-primary",
      false: "bg-border",
    },
    orientation: {
      vertical: "",
      horizontal: "",
    },
    variant: {
      default: "",
      alternate: "",
    },
    isAlternateRight: {
      true: "",
      false: "",
    },
  },
  compoundVariants: [
    {
      orientation: "vertical",
      variant: "default",
      class:
        "start-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] top-3 h-[calc(100%+0.5rem)] w-[var(--timeline-connector-thickness)]",
    },
    {
      orientation: "horizontal",
      variant: "default",
      class:
        "start-3 top-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] h-[var(--timeline-connector-thickness)] w-[calc(100%+0.5rem)]",
    },
    {
      orientation: "vertical",
      variant: "alternate",
      isAlternateRight: false,
      class:
        "-right-[calc(var(--timeline-connector-thickness)/2)] top-2 h-full w-[var(--timeline-connector-thickness)]",
    },
    {
      orientation: "vertical",
      variant: "alternate",
      isAlternateRight: true,
      class:
        "-left-[calc(var(--timeline-connector-thickness)/2)] top-2 h-full w-[var(--timeline-connector-thickness)]",
    },
    {
      orientation: "horizontal",
      variant: "alternate",
      class:
        "top-[calc(var(--timeline-dot-size)/2-var(--timeline-connector-thickness)/2)] left-3 row-start-2 h-[var(--timeline-connector-thickness)] w-[calc(100%+0.5rem)]",
    },
  ],
  defaultVariants: {
    isCompleted: false,
    orientation: "vertical",
    variant: "default",
    isAlternateRight: false,
  },
});
 
interface TimelineConnectorProps extends DivProps {
  forceMount?: boolean;
}
 
function TimelineConnector(props: TimelineConnectorProps) {
  const { asChild, forceMount, className, ...connectorProps } = props;
 
  const { orientation, variant, activeIndex } =
    useTimelineContext(CONNECTOR_NAME);
  const { id, status, isAlternateRight } =
    useTimelineItemContext(CONNECTOR_NAME);
 
  const nextItemStatus = useStore((state) =>
    state.getNextItemStatus(id, activeIndex),
  );
 
  const isLastItem = nextItemStatus === undefined;
 
  if (!forceMount && isLastItem) return null;
 
  const isConnectorCompleted =
    nextItemStatus === "completed" || nextItemStatus === "active";
 
  const ConnectorPrimitive = asChild ? Slot : "div";
 
  return (
    <ConnectorPrimitive
      aria-hidden="true"
      data-slot="timeline-connector"
      data-completed={isConnectorCompleted ? "" : undefined}
      data-status={status}
      data-orientation={orientation}
      {...connectorProps}
      className={cn(
        timelineConnectorVariants({
          isCompleted: isConnectorCompleted,
          orientation,
          variant,
          isAlternateRight,
          className,
        }),
      )}
    />
  );
}
 
function TimelineHeader(props: DivProps) {
  const { asChild, className, ...headerProps } = props;
 
  const HeaderPrimitive = asChild ? Slot : "div";
 
  return (
    <HeaderPrimitive
      data-slot="timeline-header"
      {...headerProps}
      className={cn("flex flex-col gap-1", className)}
    />
  );
}
 
function TimelineTitle(props: DivProps) {
  const { asChild, className, ...titleProps } = props;
 
  const TitlePrimitive = asChild ? Slot : "div";
 
  return (
    <TitlePrimitive
      data-slot="timeline-title"
      {...titleProps}
      className={cn("font-semibold leading-none", className)}
    />
  );
}
 
function TimelineDescription(props: DivProps) {
  const { asChild, className, ...descriptionProps } = props;
 
  const DescriptionPrimitive = asChild ? Slot : "div";
 
  return (
    <DescriptionPrimitive
      data-slot="timeline-description"
      {...descriptionProps}
      className={cn("text-muted-foreground text-sm", className)}
    />
  );
}
 
interface TimelineTimeProps extends React.ComponentProps<"time"> {
  asChild?: boolean;
}
 
function TimelineTime(props: TimelineTimeProps) {
  const { asChild, className, ...timeProps } = props;
 
  const TimePrimitive = asChild ? Slot : "time";
 
  return (
    <TimePrimitive
      data-slot="timeline-time"
      {...timeProps}
      className={cn("text-muted-foreground text-xs", className)}
    />
  );
}
 
export {
  TimelineRoot as Root,
  TimelineItem as Item,
  TimelineDot as Dot,
  TimelineConnector as Connector,
  TimelineContent as Content,
  TimelineHeader as Header,
  TimelineTitle as Title,
  TimelineDescription as Description,
  TimelineTime as Time,
  //
  TimelineRoot as Timeline,
  TimelineItem,
  TimelineDot,
  TimelineConnector,
  TimelineContent,
  TimelineHeader,
  TimelineTitle,
  TimelineDescription,
  TimelineTime,
};

Layout

import * as Timeline from "@/components/ui/timeline";

<Timeline.Root>
  <Timeline.Item>
    <Timeline.Dot />
    <Timeline.Connector />
    <Timeline.Content>
      <Timeline.Header>
        <Timeline.Title />
        <Timeline.Time />
      </Timeline.Header>
      <Timeline.Description />
    </Timeline.Content>
  </Timeline.Item>
</Timeline.Root>

Examples

Horizontal Timeline

Display timeline events horizontally across the screen.

import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineDot,
  TimelineHeader,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";
 
const timelineItems = [
  {
    id: "research-and-planning",
    dateTime: "2025-01",
    date: "Jan - Mar",
    title: "Q1",
    description: "Research and planning",
  },
  {
    id: "development-sprint",
    dateTime: "2025-04",
    date: "Apr - Jun",
    title: "Q2",
    description: "Development sprint",
  },
  {
    id: "beta-launch",
    dateTime: "2025-07",
    date: "Jul - Sep",
    title: "Q3",
    description: "Beta launch",
  },
];
 
export function TimelineHorizontalDemo() {
  return (
    <Timeline orientation="horizontal" activeIndex={1}>
      {timelineItems.map((item) => (
        <TimelineItem key={item.id}>
          <TimelineDot />
          <TimelineConnector />
          <TimelineContent>
            <TimelineHeader>
              <TimelineTitle>{item.title}</TimelineTitle>
              <TimelineTime dateTime={item.dateTime}>{item.date}</TimelineTime>
            </TimelineHeader>
            <TimelineDescription>{item.description}</TimelineDescription>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

RTL Timeline

Display timeline with right-to-left layout for RTL languages.

import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineDot,
  TimelineHeader,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";
 
const timelineItems = [
  {
    id: "registration-opened",
    dateTime: "2025-01-01",
    date: "January 1, 2025",
    title: "Registration Opened",
    description: "Online registration portal opens.",
  },
  {
    id: "early-bird-deadline",
    dateTime: "2025-02-15",
    date: "February 15, 2025",
    title: "Early Bird Deadline",
    description: "Last day for early bird pricing.",
  },
  {
    id: "event-day",
    dateTime: "2025-03-01",
    date: "March 1, 2025",
    title: "Event Day",
    description: "Main event begins at 9:00 AM.",
  },
];
 
export function TimelineRtlDemo() {
  return (
    <Timeline dir="rtl" activeIndex={1}>
      {timelineItems.map((item) => (
        <TimelineItem key={item.id}>
          <TimelineDot />
          <TimelineConnector />
          <TimelineContent>
            <TimelineHeader>
              <TimelineTitle>{item.title}</TimelineTitle>
              <TimelineTime dateTime={item.dateTime}>{item.date}</TimelineTime>
            </TimelineHeader>
            <TimelineDescription>{item.description}</TimelineDescription>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Alternate Timeline

Display timeline events in an alternating pattern with content on both sides.

import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineDot,
  TimelineHeader,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";
 
const timelineItems = [
  {
    id: "project-kickoff",
    dateTime: "2025-01-15",
    date: "January 15, 2025",
    title: "Project Kickoff",
    description: "Initial meeting to define scope.",
  },
  {
    id: "design-phase",
    dateTime: "2025-02-01",
    date: "February 1, 2025",
    title: "Design Phase",
    description: "Created wireframes and mockups.",
  },
  {
    id: "development",
    dateTime: "2025-03-01",
    date: "March 1, 2025",
    title: "Development",
    description: "Building core features.",
  },
];
 
export function TimelineAlternateDemo() {
  return (
    <Timeline variant="alternate" activeIndex={1}>
      {timelineItems.map((item) => (
        <TimelineItem key={item.id}>
          <TimelineDot />
          <TimelineConnector />
          <TimelineContent>
            <TimelineHeader>
              <TimelineTime dateTime={item.dateTime}>{item.date}</TimelineTime>
              <TimelineTitle>{item.title}</TimelineTitle>
            </TimelineHeader>
            <TimelineDescription>{item.description}</TimelineDescription>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

Horizontal Alternate Timeline

Display timeline events horizontally with content alternating above and below.

import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineDot,
  TimelineHeader,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";
 
const timelineItems = [
  {
    id: "company-founded",
    dateTime: "2023-06",
    date: "June 2023",
    title: "Company Founded",
    description: "Started with a team of five.",
  },
  {
    id: "series-a-funding",
    dateTime: "2024-03",
    date: "March 2024",
    title: "Series A Funding",
    description: "Raised $10M seed funding.",
  },
  {
    id: "product-launch",
    dateTime: "2025-01",
    date: "January 2025",
    title: "Product Launch",
    description: "Released MVP to beta testers.",
  },
];
 
export function TimelineHorizontalAlternateDemo() {
  return (
    <Timeline variant="alternate" orientation="horizontal" activeIndex={1}>
      {timelineItems.map((item) => (
        <TimelineItem key={item.id}>
          <TimelineDot />
          <TimelineConnector />
          <TimelineContent>
            <TimelineHeader>
              <TimelineTime dateTime={item.dateTime}>{item.date}</TimelineTime>
              <TimelineTitle>{item.title}</TimelineTitle>
            </TimelineHeader>
            <TimelineDescription>{item.description}</TimelineDescription>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

With Custom Dots

Add custom icons or content to the timeline dots using CSS variables.

import { Code, Layers, Rocket } from "lucide-react";
import {
  Timeline,
  TimelineConnector,
  TimelineContent,
  TimelineDescription,
  TimelineDot,
  TimelineHeader,
  TimelineItem,
  TimelineTime,
  TimelineTitle,
} from "@/components/ui/timeline";
 
const timelineItems = [
  {
    id: "project-kickoff",
    dateTime: "2025-01-15",
    date: "January 15, 2025",
    title: "Project Kickoff",
    description: "Initial meeting to define scope.",
    icon: Rocket,
  },
  {
    id: "design-phase",
    dateTime: "2025-02-01",
    date: "February 1, 2025",
    title: "Design Phase",
    description: "Created wireframes and mockups.",
    icon: Layers,
  },
  {
    id: "development",
    dateTime: "2025-03-01",
    date: "March 1, 2025",
    title: "Development",
    description: "Building core features.",
    icon: Code,
  },
];
 
export function TimelineCustomDotDemo() {
  return (
    <Timeline activeIndex={1} className="[--timeline-dot-size:2rem]">
      {timelineItems.map((item) => (
        <TimelineItem key={item.id}>
          <TimelineDot>
            <item.icon className="size-3.5" />
          </TimelineDot>
          <TimelineConnector />
          <TimelineContent>
            <TimelineHeader>
              <TimelineTime dateTime={item.dateTime}>{item.date}</TimelineTime>
              <TimelineTitle>{item.title}</TimelineTitle>
            </TimelineHeader>
            <TimelineDescription>{item.description}</TimelineDescription>
          </TimelineContent>
        </TimelineItem>
      ))}
    </Timeline>
  );
}

API Reference

Root

The root container for timeline items.

Prop

Type

Data AttributeValue
[data-orientation]"vertical" | "horizontal"
[data-variant]"default" | "alternate"
CSS VariableDescriptionDefault
--timeline-dot-sizeThe size (width and height) of the timeline dot marker.0.875rem (14px)
--timeline-connector-thicknessThe thickness of the timeline connector line.0.125rem (2px)

Item

A single timeline item containing content, marker, and connector.

Prop

Type

Data AttributeValue
[data-status]"completed" | "active" | "pending"
[data-orientation]"vertical" | "horizontal"
[data-alternate-right]"Present when item is on the right/bottom in alternate variant"

Dot

The visual marker/dot for a timeline item.

Prop

Type

Data AttributeValue
[data-status]"completed" | "active" | "pending"
[data-orientation]"vertical" | "horizontal"
CSS VariableDescriptionDefault
--timeline-dot-sizeThe size (width and height) of the timeline dot marker.0.875rem (14px)

Connector

The line connecting timeline items.

Prop

Type

Data AttributeValue
[data-completed]"Present when connector represents a completed transition"
[data-status]"completed" | "active" | "pending"
[data-orientation]"vertical" | "horizontal"
CSS VariableDescriptionDefault
--timeline-connector-thicknessThe thickness of the timeline connector line.0.125rem (2px)

Container for the title and time of a timeline item.

Prop

Type

Title

The title/heading of a timeline item.

Prop

Type

Description

The description/body text of a timeline item.

Prop

Type

Content

Container for the timeline item's content (header, description, etc.).

Prop

Type

Data AttributeValue
[data-status]"completed" | "active" | "pending"

Time

A semantic time element for displaying dates/times.

Prop

Type

Features

Flexible Orientations

The timeline supports both vertical and horizontal orientations. Use the orientation prop on Timeline.Root to switch between layouts.

Alternate Variant

The timeline supports an alternate variant where content alternates on both sides of the timeline. Use the variant="alternate" prop on Timeline.Root to enable this layout. This works with both vertical and horizontal orientations:

  • Vertical alternate: Content alternates left and right of the center line
  • Horizontal alternate: Content alternates above and below the center line
<Timeline.Root variant="alternate" orientation="horizontal">
  {/* Content alternates above and below */}
</Timeline.Root>

RTL Support

The timeline fully supports right-to-left (RTL) layouts through the dir prop. When set to "rtl", the timeline automatically flips its layout direction, making it ideal for RTL languages like Arabic, Hebrew, and Persian.

Active Index

Control the visual state of timeline items using the activeIndex prop on the root component. Items before the active index will be marked as "completed", the item at the active index will be "active", and items after will be "pending".

<Timeline.Root activeIndex={2}>
  <Timeline.Item>Step 1 - Completed</Timeline.Item>
  <Timeline.Item>Step 2 - Completed</Timeline.Item>
  <Timeline.Item>Step 3 - Active (index 2)</Timeline.Item>
  <Timeline.Item>Step 4 - Pending</Timeline.Item>
</Timeline.Root>

The activeIndex is zero-based, so activeIndex={2} makes the third item active.

Custom Icons

Replace the default dot marker with custom icons or React components by passing children to Timeline.Dot, giving you full control over the visual appearance.

Composition Pattern

Built with a composable API that gives you complete control over the structure and styling of your timeline. Mix and match components as needed.

Accessibility

ARIA Roles

The timeline uses ARIA roles and attributes for proper accessibility:

  • Root uses role="list" and aria-orientation to represent an ordered list of events
  • Each item uses role="listitem" for proper list semantics
  • Active items use aria-current="step" to indicate current position in the timeline
  • Semantic <time> elements with dateTime attribute for proper date representation
  • Connectors are marked with aria-hidden="true" as they're purely decorative