Activity Graph

A high-performance metric visualizer featuring staggered bar animations and a spring-physics tooltip. Built with Framer Motion, it provides dynamic, real-time feedback for audit data, using an architectural B/W aesthetic and reactive hover states to ensure precise data readability.

framer-motion

Activity Graph

Live Metrics+12.4%

Installation

Install the required dependencies:

npm install framer-motion
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
components/ui/activity-graph.tsx
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";

type GraphProps = {
  className?: string;
  bars?: number[];
};

export function ActivityGraph({
  className,
  bars = [40, 70, 45, 90, 65, 80, 50, 95, 60, 85],
}: GraphProps) {
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);

  return (
    <div
      className={cn(
        className,
        "p-6 rounded-4xl border w-sm border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/30",
      )}
    >
      <h4 className="text-[10px] flex items-center gap-1 font-black uppercase tracking-widest text-zinc-400 mb-6">
        Activity Graph
        <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
      </h4>
      <div className="relative flex items-end justify-between h-20 gap-1 group/graph">
        <AnimatePresence>
          {hoveredIndex !== null && (
            <motion.div
              initial={{ opacity: 0, y: 10, scale: 0.9 }}
              animate={{
                opacity: 1,
                y: 0,
                scale: 1,
                left: `${(hoveredIndex / (bars.length - 1)) * 100}%`,
              }}
              exit={{ opacity: 0, y: 10, scale: 0.9 }}
              transition={{ type: "spring", stiffness: 400, damping: 30 }}
              className="absolute -top-8 z-20 pointer-events-none"
              style={{ x: "-50%" }} // This ensures the tooltip itself is centered on its own anchor point
            >
              <div className="px-3 py-1.5 bg-zinc-900 dark:bg-white text-white dark:text-black rounded-lg text-[10px] font-black shadow-xl whitespace-nowrap">
                VALUE: {bars[hoveredIndex]}%{/* Tooltip Arrow */}
                <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-zinc-900 dark:bg-white rotate-45" />
              </div>
            </motion.div>
          )}
        </AnimatePresence>

        {bars.map((height, i) => (
          <motion.div
            key={i}
            initial={{ height: 0 }}
            animate={{ height: `${height}%` }}
            onMouseEnter={() => setHoveredIndex(i)}
            onMouseLeave={() => setHoveredIndex(null)}
            transition={{ delay: i * 0.05, duration: 0.8, ease: "circOut" }}
            className="flex-1 bg-zinc-900 dark:bg-white rounded-t-sm opacity-20 hover:opacity-100 transition-opacity cursor-crosshair relative"
          />
        ))}
      </div>

      <div className="mt-6 flex justify-between items-center">
        <span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tighter">
          Live Metrics
        </span>
        <span className="text-xs font-black dark:text-white">+12.4%</span>
      </div>
    </div>
  );
}