Docs

Animated Timeline

The Animated Timeline component is an interactive, visually appealing timeline that responds to user interaction. Built with Framer Motion and React, this component highlights key events or milestones in a vertical timeline structure.When a user hovers over a specific timeline item, the associated circular dot and all previous dots in the sequence turn green, indicating progression. The dot size also enlarges slightly, enhancing the focus on the current event. The component offers a sleek and smooth animation experience, perfect for showcasing chronological steps, milestones, or achievements in an engaging and user-friendly manner.This component is highly customizable, allowing easy modifications to the timeline content and styling, making it suitable for diverse applications such as resumes, project timelines, or product roadmaps.

Installation

Install dependencies

npm install framer-motion lucide-react

Run the following command

It will create a new file animatedtimeline.tsx inside the components/animata/progress directory.

mkdir -p components/animata/progress && touch components/animata/progress/animatedtimeline.tsx

Paste the code

Open the newly created file and paste the following code:

"use client";
 
import React, { useState } from "react";
import { motion } from "framer-motion";
 
import { cn } from "@/lib/utils";
 
export interface TimelineEvent {
  id: string;
  title: string;
  description?: string;
  date?: string;
  [key: string]: unknown; // Allow additional custom fields
}
 
interface TimelineItemProps {
  event: TimelineEvent;
  isActive: boolean;
  isFirst: boolean;
  isLast: boolean;
  onHover: (index: number | null) => void;
  index: number;
  activeIndex: number | null;
  styles: TimelineStyles;
  customRender?: (event: TimelineEvent) => React.ReactNode;
}
 
interface TimelineStyles {
  lineColor: string;
  activeLineColor: string;
  dotColor: string;
  activeDotColor: string;
  dotSize: string;
  titleColor: string;
  descriptionColor: string;
  dateColor: string;
}
 
const TimelineItem: React.FC<TimelineItemProps> = ({
  event,
  isActive,
  isLast,
  onHover,
  index,
  activeIndex,
  styles,
  customRender,
}) => {
  const fillDelay = activeIndex !== null ? Math.max(0, (index - 1) * 0.1) : 0;
  const fillDuration = activeIndex !== null ? Math.max(0.2, 0.5 - index * 0.1) : 0.5;
 
  return (
    <motion.div
      className="flex last:mb-0"
      onHoverStart={() => onHover(index)}
      onHoverEnd={() => onHover(null)}
      initial={{ opacity: 0, y: 50 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.5 }}
    >
      <div className="relative mr-4 flex flex-col items-center">
        <div
          className={`absolute ${isLast ? "hidden" : "block"} bottom-0 top-0 w-1`}
          style={{ backgroundColor: styles.lineColor }}
        >
          <motion.div
            className="w-full origin-top"
            initial={{ scaleY: 0 }}
            animate={{ scaleY: isActive ? 1 : 0 }}
            transition={{ duration: fillDuration, delay: fillDelay }}
            style={{ height: "100%", backgroundColor: styles.activeLineColor }}
          />
        </div>
        <motion.div
          className="relative z-10 rounded-full border-4"
          style={{
            width: styles.dotSize,
            height: styles.dotSize,
            borderColor: isActive ? styles.activeDotColor : styles.dotColor,
            backgroundColor: isActive ? styles.activeDotColor : "Background",
          }}
          animate={{
            scale: isActive ? 1.2 : 1,
            backgroundColor: isActive ? styles.activeDotColor : "Background",
            borderColor: isActive ? styles.activeDotColor : styles.dotColor,
          }}
          transition={{ duration: fillDuration, delay: fillDelay }}
        />
      </div>
      <div className={cn("flex-grow leading-5", !isLast && "mb-3")}>
        {customRender ? (
          customRender(event)
        ) : (
          <>
            <h3 className="text-lg font-semibold" style={{ color: styles.titleColor }}>
              {event.title}
            </h3>
            <p style={{ color: styles.descriptionColor }}>{event.description}</p>
            <span className="text-sm" style={{ color: styles.dateColor }}>
              {event.date}
            </span>
          </>
        )}
      </div>
    </motion.div>
  );
};
 
interface AnimatedTimelineProps {
  events: TimelineEvent[];
  className?: string;
  styles?: Partial<TimelineStyles>;
  customEventRender?: (event: TimelineEvent) => React.ReactNode;
  onEventHover?: (event: TimelineEvent | null) => void;
  onEventClick?: (event: TimelineEvent) => void;
  initialActiveIndex?: number;
}
 
const defaultStyles: TimelineStyles = {
  lineColor: "#d1d5db",
  activeLineColor: "#22c55e",
  dotColor: "#d1d5db",
  activeDotColor: "#22c55e",
  dotSize: "1.5rem",
  titleColor: "inherit",
  descriptionColor: "inherit",
  dateColor: "inherit",
};
 
export function AnimatedTimeline({
  events,
  className = "",
  styles: customStyles = {},
  customEventRender,
  onEventHover,
  onEventClick,
  initialActiveIndex,
}: AnimatedTimelineProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(initialActiveIndex ?? null);
  const styles = { ...defaultStyles, ...customStyles };
 
  const handleHover = (index: number | null) => {
    setActiveIndex(index);
    onEventHover?.(index !== null ? events[index] : null);
  };
 
  return (
    <div className={`relative py-4 ${className}`}>
      {events.map((event, index) => (
        <div key={event.id} onClick={() => onEventClick?.(event)}>
          <TimelineItem
            event={event}
            isActive={activeIndex !== null && index <= activeIndex}
            isFirst={index === 0}
            isLast={index === events.length - 1}
            onHover={handleHover}
            index={index}
            activeIndex={activeIndex}
            styles={styles}
            customRender={customEventRender}
          />
        </div>
      ))}
    </div>
  );
}
 
interface AnimatedTimelinePageProps {
  events?: TimelineEvent[];
  title?: string;
  containerClassName?: string;
  timelineStyles?: Partial<TimelineStyles>;
  customEventRender?: (events: TimelineEvent) => React.ReactNode;
  onEventHover?: (events: TimelineEvent | null) => void;
  onEventClick?: (events: TimelineEvent) => void;
  initialActiveIndex?: number;
}
 
export default function AnimatedTimelinePage({
  events,
  title,
  containerClassName,
  timelineStyles,
  customEventRender,
  onEventHover,
  onEventClick,
  initialActiveIndex,
}: AnimatedTimelinePageProps) {
  const DefaultEvents = [
    { id: "1", title: "Event 1", description: "Description 1", date: "2023-01-01" },
    { id: "2", title: "Event 2", description: "Description 2", date: "2023-02-01" },
    { id: "3", title: "Event 3", description: "Description 3", date: "2023-03-01" },
  ];
  const defaultTitle = "Timeline";
 
  return (
    <div
      className={cn(
        "container mx-auto rounded-lg bg-background px-8 pt-6 text-foreground",
        containerClassName,
      )}
    >
      <h1 className="text-2xl font-bold">{title || defaultTitle}</h1>
      <AnimatedTimeline
        events={events || DefaultEvents}
        className="max-w-2xl"
        styles={timelineStyles}
        customEventRender={customEventRender}
        onEventHover={onEventHover}
        onEventClick={onEventClick}
        initialActiveIndex={initialActiveIndex}
      />
    </div>
  );
}

Credits

Built by Vishal Kumar