Staggered Bar Chart

A simple animated bar chart for Next.js. The bars animate in when entering the screen, and it will shows a tooltip when the bar is hovered.

  • Next.js
  • Typescript
  • GSAP
  • ScrollTrigger Plugin
  • useGSAP Plugin
  • Tailwind CSS
components/staggered-bar-chart.tsx
"use client";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useRef, useState } from "react";

gsap.registerPlugin(useGSAP, ScrollTrigger);

export function StaggeredBarChart({
  data,
  maxHeight = 250,
}: {
  data: Array<number>;
  maxHeight?: number;
}) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const tooltipRef = useRef<HTMLDivElement | null>(null);
  const barRefs = useRef<Array<HTMLDivElement>>([]);
  const [hoveredData, setHoveredData] = useState<number | null>(null);
  const [isHoveringBars, setIsHoveringBars] = useState(false);

  useGSAP(
    () => {
      const bars = barRefs.current;

      gsap.from(bars, {
        scaleY: 0,
        transformOrigin: "50% 100%",
        stagger: 0.015,
        ease: "power1.inOut",
        duration: 0.3,
        delay: 0.5,
        scrollTrigger: {
          trigger: containerRef.current,
          start: "top 90%",
        },
      });
    },
    { scope: containerRef }
  );

  // linear normalization
  const maxValue = Math.max(...data);
  const scaledData = data.map((value) => (value / maxValue) * maxHeight);

  const handleMouseEnter = (index: number) => {
    setHoveredData(data[index]);
    setIsHoveringBars(true);

    const bar = barRefs.current[index];

    gsap.to(bar, {
      scaleX: 2,
      opacity: 1,
      ease: "power1.out",
      duration: 0.5,
    });

    const tooltip = tooltipRef.current;
    if (!tooltip) return;

    if (!isHoveringBars) {
      gsap.set(tooltipRef.current, {
        scale: 0.1,
        opacity: 0,
        y: -scaledData[index],
        x: bar.offsetLeft - 24 - tooltip.offsetWidth / 2,
        ease: "power4.out",
      });
    }

    gsap.to(tooltipRef.current, {
      transformOrigin: "50% 100%",
      scale: 1,
      y: -scaledData[index] - 12,
      x: bar.offsetLeft - 24 - tooltip.offsetWidth / 2,
      opacity: 1,
      duration: 0.5,
      ease: "power4.out",
    });
  };

  const handleMouseLeave = (index: number) => {
    const bar = barRefs.current[index];

    gsap.to(bar, {
      scaleX: 1,
      opacity: 0.2,
      ease: "power1.out",
      duration: 0.4,
      delay: 0.2,
    });

    const tooltip = tooltipRef.current;
    if (!tooltip) return;

    setIsHoveringBars(false);
    gsap.to(tooltipRef.current, {
      scale: 0.1,
      opacity: 0,
      y: -scaledData[index],
      x: bar.offsetLeft - 24 - tooltip.offsetWidth / 2,
      ease: "power4.out",
    });
  };

  return (
    <div
      ref={containerRef}
      className="flex items-end bg-texture w-fit p-6 rounded relative"
    >
      {data.map((item, index) => (
        <div
          key={index}
          className="bar-container w-3 overflow-hidden"
          onMouseEnter={() => handleMouseEnter(index)}
          onMouseLeave={() => handleMouseLeave(index)}
        >
          <div
            className="bar w-0.5 bg-blue-600 opacity-30 mx-auto will-change-[width]"
            style={{ height: scaledData[index] }}
            ref={(el) => {
              if (el) barRefs.current[index] = el;
            }}
          ></div>
        </div>
      ))}
      <div
        ref={tooltipRef}
        className="bg-blue-600 px-3 py-1 rounded-full text-white font-sans text-xs absolute pointer-events-none opacity-0"
      >
        {hoveredData}
      </div>
    </div>
  );
}


<StaggeredBarChart
  data={[
    20, 28, 54, 120, 124, 110, 102, 149, 240, 270, 298, 340, 380, 390, 405, 415,
    402, 390, 375, 366, 356, 362, 390, 410, 422, 434, 445, 467
  ]}
/>