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-motionPreview
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>
);
}