Timeline

Simple timeline with clean connectors, multiple variants, and asChild support for custom elements.

Project Kickoff

Design Phase

Development

Vertical Timeline

Project Kickoff

Design Phase

Horizontal Timeline

Project Kickoff

Design Phase

Development

Responsive Timeline

Library setup

npm install @radix-ui/react-slot

Code

"use client";

import { HTMLMotionProps, motion } from "motion/react";

import React, {
  createContext,
  useCallback,
  useContext,
  useLayoutEffect,
  useRef,
  useState,
} from "react";

import { Slot } from "@radix-ui/react-slot";

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

type TimelineContextValue = {
  activeStep: number;
  setActiveStep: (val: number) => void;
  isInView: boolean;
  orientation: "horizontal" | "vertical";
};

interface TimelineDateProps extends React.HTMLAttributes<HTMLTimeElement> {
  asChild?: boolean;
}

interface TimelineProps extends HTMLMotionProps<"div"> {
  defaultValue?: number;
  value?: number;
  onValueChange?: (val: number) => void;
  orientation?: "horizontal" | "vertical";
}

interface TimelineIndicatorProps extends HTMLMotionProps<"div"> {}

interface TimelineItemProps extends HTMLMotionProps<"div"> {
  step: number;
}

interface TimelineSeparatorProps extends HTMLMotionProps<"div"> {
  variant?: "solid" | "dashed" | "dotted";
}

const TimelineContext = createContext<TimelineContextValue | null>(null);

const useTimeline = () => {
  const ctx = useContext(TimelineContext);
  if (!ctx) throw new Error("useTimeline must be used within a Timeline");
  return ctx;
};

const Timeline = ({
  defaultValue = 1,
  value,
  onValueChange,
  orientation = "vertical",
  className,
  ...rest
}: TimelineProps) => {
  const [internal, setInternal] = useState(defaultValue);
  const [isInView, setIsInView] = useState(false);

  const update = useCallback(
    (val: number) => {
      if (value == null) setInternal(val);
      onValueChange?.(val);
    },
    [value, onValueChange],
  );

  const current = value ?? internal;

  return (
    <TimelineContext.Provider
      value={{
        activeStep: current,
        setActiveStep: update,
        isInView,
        orientation,
      }}
    >
      <motion.div
        data-slot="timeline"
        data-orientation={orientation}
        className={cn(
          "group/timeline flex",
          "data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row",
          "data-[orientation=vertical]:flex-col",
          className,
        )}
        onViewportEnter={() => setIsInView(true)}
        viewport={{ once: true, margin: "-100px" }}
        {...rest}
      />
    </TimelineContext.Provider>
  );
};

const TimelineContent = ({ className, ...rest }: HTMLMotionProps<"div">) => {
  const { isInView } = useTimeline();
  return (
    <motion.div
      data-slot="timeline-content"
      className={cn("text-muted-foreground text-sm", className)}
      initial={{ opacity: 0, y: 20 }}
      animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
      transition={{ duration: 0.4, ease: "easeOut" }}
      {...rest}
    />
  );
};

const TimelineDate = ({
  asChild = false,
  className,
  ...rest
}: TimelineDateProps) => {
  const Component = asChild ? Slot : "time";
  const { isInView } = useTimeline();

  return (
    <motion.div
      initial={{ opacity: 0, y: 12 }}
      animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
      transition={{ duration: 0.4, ease: "easeOut", delay: 0.1 }}
    >
      <Component
        data-slot="timeline-date"
        className={cn(
          "text-muted-foreground mb-1 block text-xs font-medium",
          className,
        )}
        {...rest}
      />
    </motion.div>
  );
};

const TimelineHeader = ({
  className,
  ...rest
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div data-slot="timeline-header" className={cn(className)} {...rest} />
);

const TimelineIndicator = ({
  className,
  children,
  ...rest
}: TimelineIndicatorProps) => {
  const { isInView, orientation } = useTimeline();

  return (
    <motion.div
      data-slot="timeline-indicator"
      className={cn(
        "absolute size-4 rounded-full border-2",
        "border-primary/20 group-data-completed/timeline-item:border-primary group-data-completed/timeline-item:bg-primary",
        orientation === "vertical"
          ? "top-0 -left-8 -translate-x-1/2"
          : "-top-8 left-0 -translate-y-1/2",
        className,
      )}
      initial={{ opacity: 0 }}
      animate={isInView ? { opacity: 1 } : { opacity: 0 }}
      transition={{ duration: 0.4, ease: "easeOut" }}
      aria-hidden="true"
      {...rest}
    >
      {children}
    </motion.div>
  );
};

const TimelineItem = ({
  step,
  className,
  children,
  ...rest
}: TimelineItemProps) => {
  const { activeStep, isInView, orientation } = useTimeline();
  const ref = useRef<HTMLDivElement>(null);
  const [size, setSize] = useState(0);

  useLayoutEffect(() => {
    if (!ref.current) return;
    const observer = new ResizeObserver(() => {
      const rect = ref.current?.getBoundingClientRect();
      setSize(
        orientation === "vertical" ? (rect?.height ?? 0) : (rect?.width ?? 0),
      );
    });
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [orientation]);

  return (
    <motion.div
      ref={ref}
      data-slot="timeline-item"
      data-completed={step <= activeStep || null}
      className={cn(
        "group/timeline-item relative flex flex-1 flex-col gap-0.5",
        "group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8",
        "group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-6",
        className,
      )}
      style={
        {
          "--item-size": `${size}px`,
        } as React.CSSProperties
      }
      initial={{ opacity: 0, filter: "blur(2px)" }}
      animate={
        isInView
          ? { opacity: 1, filter: "blur(0px)" }
          : { opacity: 0, filter: "blur(2px)" }
      }
      transition={{ duration: 0.6, ease: "easeOut", delay: step * 0.1 }}
      {...rest}
    >
      {children}
    </motion.div>
  );
};

const TimelineSeparator = ({
  className,
  variant = "solid",
  ...rest
}: TimelineSeparatorProps) => {
  const { isInView, orientation } = useTimeline();

  return (
    <motion.div
      data-slot="timeline-separator"
      aria-hidden="true"
      className={cn(
        "border-primary/20 group-data-completed/timeline-item:border-primary absolute self-start group-last/timeline-item:hidden",
        orientation === "vertical"
          ? "top-5 -left-8 -translate-x-1/2 border-l-2"
          : "-top-8 left-5 -translate-y-1/2 border-t-2",
        {
          "border-solid": variant === "solid",
          "border-dashed": variant === "dashed",
          "border-dotted": variant === "dotted",
        },
        className,
      )}
      style={
        orientation === "vertical"
          ? { height: "calc(var(--item-size) - 24px)" }
          : { width: "calc(var(--item-size) - 24px)" }
      }
      initial={{ opacity: 0 }}
      animate={isInView ? { opacity: 1 } : { opacity: 0 }}
      transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
      {...rest}
    />
  );
};

const TimelineTitle = ({ className, ...rest }: HTMLMotionProps<"div">) => {
  const { isInView } = useTimeline();

  return (
    <motion.h3
      data-slot="timeline-title"
      className={cn("text-sm font-medium", className)}
      initial={{ opacity: 0, y: 16 }}
      animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
      transition={{ duration: 0.4, ease: "easeOut", delay: 0.2 }}
      {...rest}
    />
  );
};

export {
  Timeline,
  TimelineContent,
  TimelineDate,
  TimelineHeader,
  TimelineIndicator,
  TimelineItem,
  TimelineSeparator,
  TimelineTitle,
};