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-motion
Preview

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