Grow Bars

Bar chart where bars rise from zero with a spring overshoot and stagger, then highlight with a guide line and tooltip on hover

chartbar-chartdata-vizanimationspringtooltip
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";

export interface GrowBarsProps {
  data: { label: string; value: number; color?: string }[];
  height?: number;
  barWidth?: number;
  gap?: number;
  /** Stagger between each bar (ms). Default 70. */
  stagger?: number;
  /** Bar fill when no per-bar color is set. Includes a top sheen overlay. */
  defaultColor?: string;
  /** Glow color projected beneath each bar — must be rgba(). Default soft violet. */
  glowColor?: string;
  /** Dashed average line across the chart. Default true. */
  showAverage?: boolean;
  /** Faint baseline rule under the bars. Default true. */
  showBaseline?: boolean;
  /** Value suffix for the tooltip (e.g. "%", "k"). Default "". */
  valueSuffix?: string;
  className?: string;
}

const DEFAULT_BAR_BG =
  "linear-gradient(180deg, rgba(255,255,255,0.32), rgba(255,255,255,0) 45%), " +
  "linear-gradient(180deg, #c4b5fd 0%, #8b5cf6 55%, #6d28d9 100%)";

function withAlpha(rgba: string, alpha: number) {
  const m = /rgba?\(([^)]+)\)/.exec(rgba);
  if (!m) return rgba;
  const parts = m[1].split(",").map((p) => p.trim()).slice(0, 3);
  return `rgba(${parts.join(", ")}, ${alpha})`;
}

export default function GrowBars({
  data,
  height = 220,
  barWidth = 36,
  gap = 22,
  stagger = 70,
  defaultColor = DEFAULT_BAR_BG,
  glowColor = "rgba(139, 92, 246, 0.55)",
  showAverage = true,
  showBaseline = true,
  valueSuffix = "",
  className = "",
}: GrowBarsProps) {
  const [hover, setHover] = useState<number | null>(null);

  if (!data?.length) return null;
  const max = Math.max(...data.map((d) => d.value), 1);
  const avg = data.reduce((s, d) => s + d.value, 0) / data.length;
  const glowDim = withAlpha(glowColor, 0.32);
  const glowHot = withAlpha(glowColor, 0.85);

  return (
    <div className={`relative inline-flex flex-col ${className}`}>
      <div
        className="relative flex items-end"
        style={{ height, gap }}
        onMouseLeave={() => setHover(null)}
      >
        {showAverage && (
          <div
            className="pointer-events-none absolute left-0 right-0 flex items-center"
            style={{ bottom: `${(avg / max) * 100}%` }}
          >
            <span className="mr-2 shrink-0 rounded-full border border-white/10 bg-[#0d0a14]/90 px-1.5 py-px font-mono text-[10px] text-white/55">
              avg {Math.round(avg)}
            </span>
            <div
              className="h-px flex-1"
              style={{
                backgroundImage:
                  "linear-gradient(90deg, rgba(255,255,255,0.22) 0 6px, transparent 6px 12px)",
                backgroundSize: "12px 1px",
              }}
            />
          </div>
        )}

        {data.map((d, i) => {
          const pct = (d.value / max) * 100;
          const fill = d.color ?? defaultColor;
          const isActive = hover === i;
          const radius = Math.min(barWidth / 2.2, 18);
          return (
            <div
              key={d.label}
              className="relative flex flex-col items-center"
              style={{ width: barWidth, height }}
              onMouseEnter={() => setHover(i)}
            >
              <motion.div
                initial={{ scaleY: 0 }}
                animate={{ scaleY: 1 }}
                transition={{
                  type: "spring",
                  stiffness: 160,
                  damping: 18,
                  delay: (i * stagger) / 1000,
                }}
                whileHover={{ scaleY: 1.04 }}
                className="absolute bottom-0 left-0 right-0"
                style={{
                  height: `${pct}%`,
                  background: fill,
                  borderRadius: `${radius}px ${radius}px 4px 4px`,
                  transformOrigin: "bottom",
                  boxShadow: isActive
                    ? `0 22px 50px -10px ${glowHot}, 0 4px 14px -2px ${glowColor}, inset 0 1px 0 rgba(255,255,255,0.22)`
                    : `0 14px 34px -10px ${glowColor}, 0 2px 8px -2px ${glowDim}, inset 0 1px 0 rgba(255,255,255,0.14)`,
                  filter: isActive
                    ? "saturate(1.15) brightness(1.08)"
                    : "saturate(1) brightness(1)",
                  transition: "filter 220ms ease, box-shadow 220ms ease",
                  willChange: "transform",
                }}
              />

              <AnimatePresence>
                {isActive && (
                  <motion.div
                    key="guide"
                    initial={{ opacity: 0, scaleY: 0.6 }}
                    animate={{ opacity: 1, scaleY: 1 }}
                    exit={{ opacity: 0, scaleY: 0.6 }}
                    transition={{ duration: 0.2 }}
                    className="pointer-events-none absolute bottom-0"
                    style={{
                      left: barWidth / 2 - 0.5,
                      width: 1,
                      height,
                      background:
                        "linear-gradient(180deg, transparent, rgba(255,255,255,0.32) 30%, rgba(255,255,255,0.12) 100%)",
                      transformOrigin: "bottom",
                    }}
                  />
                )}
              </AnimatePresence>
            </div>
          );
        })}

        {showBaseline && (
          <div
            className="pointer-events-none absolute -bottom-px left-0 right-0 h-px"
            style={{
              background:
                "linear-gradient(90deg, transparent, rgba(255,255,255,0.18) 18%, rgba(255,255,255,0.18) 82%, transparent)",
            }}
          />
        )}

        <AnimatePresence>
          {hover !== null && (
            <motion.div
              key={`tip-${hover}`}
              initial={{ opacity: 0, y: 6, scale: 0.94 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, y: 6, scale: 0.94 }}
              transition={{ type: "spring", stiffness: 380, damping: 28 }}
              className="pointer-events-none absolute z-10 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-1.5"
              style={{
                left: hover * (barWidth + gap) + barWidth / 2,
                top: -18,
                transform: "translate(-50%, -100%)",
                background:
                  "linear-gradient(180deg, rgba(28,25,38,0.98), rgba(15,15,25,0.98))",
                border: "1px solid rgba(255,255,255,0.1)",
                boxShadow:
                  "0 18px 44px -12px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.04)",
              }}
            >
              <span className="font-mono text-sm tracking-tight text-white">
                {data[hover].value}
                {valueSuffix}
              </span>
              <span className="ml-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/45">
                {data[hover].label}
              </span>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <div className="mt-4 flex" style={{ gap }}>
        {data.map((d, i) => (
          <div
            key={d.label}
            className="text-center text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors duration-200"
            style={{
              width: barWidth,
              color: hover === i ? "rgba(255,255,255,0.95)" : "rgba(255,255,255,0.42)",
            }}
          >
            {d.label}
          </div>
        ))}
      </div>
    </div>
  );
}