Sparkline Chart
Animated SVG sparkline that draws in on mount and reveals a tracking dot with tooltip on hover
chartsparklinedata-vizsvganimation
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export interface SparklineChartProps {
data: number[];
width?: number;
height?: number;
stroke?: string;
fill?: string;
/** Draw-in duration in seconds. Default: 1.5 */
drawDuration?: number;
/** Show hover dot + tooltip. Default: true */
interactive?: boolean;
/** Optional suffix for tooltip value (e.g. '$', 'ms'). Default: '' */
valueSuffix?: string;
className?: string;
}
const PADDING = 8;
function normalize(data: number[], width: number, height: number) {
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const usableWidth = width;
const usableHeight = height - PADDING * 2;
return data.map((v, i) => ({
x: (i / (data.length - 1)) * usableWidth,
y: PADDING + (1 - (v - min) / range) * usableHeight,
value: v,
}));
}
function buildLinePath(points: { x: number; y: number }[]) {
return points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(" ");
}
function buildAreaPath(points: { x: number; y: number }[], height: number) {
const line = buildLinePath(points);
const last = points[points.length - 1];
const first = points[0];
return `${line} L ${last.x.toFixed(2)} ${height} L ${first.x.toFixed(2)} ${height} Z`;
}
export default function SparklineChart({
data,
width = 320,
height = 80,
stroke = "#8b5cf6",
fill = "rgba(139,92,246,0.2)",
drawDuration = 1.5,
interactive = true,
valueSuffix = "",
className = "",
}: SparklineChartProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
if (!data || data.length < 2) return null;
const points = normalize(data, width, height);
const linePath = buildLinePath(points);
const areaPath = buildAreaPath(points, height);
const hoveredPoint = hoverIndex !== null ? points[hoverIndex] : null;
const hoveredValue = hoverIndex !== null ? data[hoverIndex] : null;
function handleMouseMove(e: React.MouseEvent<SVGSVGElement>) {
if (!interactive || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const scaleX = width / rect.width;
const scaledX = cursorX * scaleX;
let closest = 0;
let minDist = Infinity;
points.forEach((p, i) => {
const dist = Math.abs(p.x - scaledX);
if (dist < minDist) {
minDist = dist;
closest = i;
}
});
setHoverIndex(closest);
}
function handleMouseLeave() {
setHoverIndex(null);
}
const tooltipX = hoveredPoint
? (hoveredPoint.x / width) * 100
: 0;
return (
<div className={`relative inline-block ${className}`} style={{ width, height }}>
{/* Tooltip */}
<AnimatePresence>
{interactive && hoveredPoint !== null && hoveredValue !== null && (
<motion.div
key="tooltip"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute z-10"
style={{
left: `${tooltipX}%`,
top: hoveredPoint.y - 36,
transform: "translateX(-50%)",
}}
>
<div
className="rounded px-2 py-1 text-xs font-semibold whitespace-nowrap"
style={{
background: "rgba(15,15,30,0.92)",
border: `1px solid ${stroke}`,
color: "#fff",
}}
>
{hoveredValue}
{valueSuffix}
</div>
</motion.div>
)}
</AnimatePresence>
{/* SVG */}
<svg
ref={svgRef}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="overflow-visible"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ display: "block" }}
>
{/* Area fill — fades in after line finishes */}
<motion.path
d={areaPath}
fill={fill}
stroke="none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: drawDuration }}
/>
{/* Line — draws in */}
<motion.path
d={linePath}
fill="none"
stroke={stroke}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: drawDuration, ease: "easeOut" }}
/>
{/* Hover dot */}
<AnimatePresence>
{interactive && hoveredPoint !== null && (
<motion.circle
key="dot"
cx={hoveredPoint.x}
cy={hoveredPoint.y}
r={4}
fill={stroke}
stroke="#fff"
strokeWidth={2}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.15 }}
style={{ originX: `${hoveredPoint.x}px`, originY: `${hoveredPoint.y}px` }}
/>
)}
</AnimatePresence>
</svg>
</div>
);
}