dqnamoexperiments / animated-spark-chart

Animated Spark Chart

A compact SVG sparkline that draws itself, morphs between trend states, and shifts color for positive or negative movement.

Revenue

$12,478

42%

Curve

Trend
+42%
import { type CSSProperties, memo, useId, useMemo } from "react";import { cn } from "@/helpers/classname-helper";export type SparklineCurve = "sharp" | "smooth";export type SparklinePoint = {  label?: string;  value: number;};type SparkCoordinate = {  x: number;  y: number;};type SparklineProps = {  ariaLabel?: string;  className?: string;  color?: string;  curve?: SparklineCurve;  data: readonly SparklinePoint[];  duration?: number;  glow?: boolean;  height?: number;  replayKey?: number | string;  showEndpoint?: boolean;  strokeWidth?: number;  width?: number;};function getSparkCoordinates({  data,  height,  width,}: {  data: readonly SparklinePoint[];  height: number;  width: number;}) {  const values = data.map((point) => point.value);  const min = Math.min(...values);  const max = Math.max(...values);  const range = max - min || 1;  const horizontalPadding = 6;  const verticalPadding = 4;  const innerWidth = width - horizontalPadding * 2;  const innerHeight = height - verticalPadding * 2;  return data.map((point, index) => {    const x =      horizontalPadding + (index / Math.max(data.length - 1, 1)) * innerWidth;    const y =      verticalPadding +      innerHeight -      ((point.value - min) / range) * innerHeight;    return { x, y };  });}function buildSharpSparkPath(points: readonly SparkCoordinate[]) {  if (points.length === 0) {    return "";  }  return points.reduce(    (path, point, index) =>      `${path} ${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`,    "",  );}function buildSmoothSparkPath(points: readonly SparkCoordinate[]) {  if (points.length <= 2) {    return buildSharpSparkPath(points);  }  return points.slice(1).reduce(    (path, point, index) => {      const previousPoint = points[index];      const beforePreviousPoint = points[index - 1] ?? previousPoint;      const nextPoint = points[index + 2] ?? point;      const controlPointOne = {        x: previousPoint.x + (point.x - beforePreviousPoint.x) / 6,        y: previousPoint.y + (point.y - beforePreviousPoint.y) / 6,      };      const controlPointTwo = {        x: point.x - (nextPoint.x - previousPoint.x) / 6,        y: point.y - (nextPoint.y - previousPoint.y) / 6,      };      return `${path} C ${controlPointOne.x.toFixed(2)} ${controlPointOne.y.toFixed(2)}, ${controlPointTwo.x.toFixed(2)} ${controlPointTwo.y.toFixed(2)}, ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;    },    `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`,  );}function buildSparkPath(  points: readonly SparkCoordinate[],  curve: SparklineCurve,) {  return curve === "smooth"    ? buildSmoothSparkPath(points)    : buildSharpSparkPath(points);}function getVisibleSparkPoints(  points: readonly SparkCoordinate[],  curve: SparklineCurve,) {  if (curve === "smooth" || points.length <= 18) {    return points;  }  const step = Math.ceil(points.length / 18);  const sharpPoints = points.filter((_, index) => index % step === 0);  const lastPoint = points.at(-1);  if (lastPoint && sharpPoints.at(-1) !== lastPoint) {    sharpPoints.push(lastPoint);  }  return sharpPoints;}export const Sparkline = memo(function Sparkline({  ariaLabel = "Sparkline",  className,  color = "currentColor",  curve = "smooth",  data,  duration = 980,  glow = false,  height = 72,  replayKey = 0,  showEndpoint = false,  strokeWidth = 2,  width = 220,}: SparklineProps) {  const sparklineId = useId().replace(/:/g, "");  const points = useMemo(    () => getSparkCoordinates({ data, height, width }),    [data, height, width],  );  const visiblePoints = useMemo(    () => getVisibleSparkPoints(points, curve),    [points, curve],  );  const path = useMemo(    () => buildSparkPath(visiblePoints, curve),    [visiblePoints, curve],  );  const lastPoint = visiblePoints.at(-1);  const animationKey = `${replayKey}-${duration}`;  const clipBleed = glow ? 18 : Math.ceil(strokeWidth / 2);  const clipSize = {    height: height + clipBleed * 2,    width: width + clipBleed * 2,    x: -clipBleed,    y: -clipBleed,  };  const strokeLinecap = curve === "sharp" ? "butt" : "round";  const strokeLinejoin = curve === "sharp" ? "miter" : "round";  return (    <svg      aria-label={ariaLabel}      className={cn("block h-auto w-full overflow-visible", className)}      key={animationKey}      preserveAspectRatio="none"      role="img"      style={{ color } as CSSProperties}      viewBox={`0 0 ${width} ${height}`}    >      <defs>        <linearGradient          id={`${sparklineId}-fade`}          gradientUnits="userSpaceOnUse"          x1="0"          x2={width}          y1="0"          y2="0"        >          <stop offset="0%" stopColor="white" stopOpacity="0" />          <stop offset="40%" stopColor="white" stopOpacity="1" />          <stop offset="100%" stopColor="white" stopOpacity="1" />        </linearGradient>        <mask          height={clipSize.height}          id={`${sparklineId}-fade-mask`}          maskUnits="userSpaceOnUse"          width={clipSize.width}          x={clipSize.x}          y={clipSize.y}        >          <rect            fill={`url(#${sparklineId}-fade)`}            height={clipSize.height}            width={clipSize.width}            x={clipSize.x}            y={clipSize.y}          />        </mask>        <clipPath id={`${sparklineId}-clip`}>          <rect            height={clipSize.height}            key={animationKey}            width={duration > 0 ? 0 : clipSize.width}            x={clipSize.x}            y={clipSize.y}          >            {duration > 0 ? (              <animate                attributeName="width"                calcMode="spline"                dur={`${duration}ms`}                fill="freeze"                from="0"                keySplines="0.25 1 0.5 1"                keyTimes="0;1"                to={clipSize.width}              />            ) : null}          </rect>        </clipPath>      </defs>      <g        clipPath={`url(#${sparklineId}-clip)`}        mask={`url(#${sparklineId}-fade-mask)`}      >        {glow ? (          <>            <path              className="fill-none stroke-current opacity-10 blur-[6px]"              d={path}              strokeLinecap={strokeLinecap}              strokeLinejoin={strokeLinejoin}              strokeWidth={strokeWidth + 8}              vectorEffect="non-scaling-stroke"            />            <path              className="fill-none stroke-current opacity-20 blur-[2px]"              d={path}              strokeLinecap={strokeLinecap}              strokeLinejoin={strokeLinejoin}              strokeWidth={strokeWidth + 3}              vectorEffect="non-scaling-stroke"            />          </>        ) : null}        <path          className="fill-none stroke-current"          d={path}          strokeLinecap={strokeLinecap}          strokeLinejoin={strokeLinejoin}          strokeWidth={strokeWidth}          vectorEffect="non-scaling-stroke"        />      </g>      {showEndpoint && lastPoint ? (        <circle          className="fill-current [filter:drop-shadow(0_0_5px_currentColor)]"          cx={lastPoint.x}          cy={lastPoint.y}          key={`${animationKey}-endpoint`}          opacity={duration > 0 ? "0" : "1"}          r={strokeWidth + 1}        >          {duration > 0 ? (            <animate              attributeName="opacity"              dur={`${duration}ms`}              fill="freeze"              keyTimes="0;0.78;1"              values="0;0;1"            />          ) : null}        </circle>      ) : null}    </svg>  );});

Want to work with me?

I run a lil design engineering studio in London where we do fractional design engineering for startups.

THEINTERFACECOMPANYOF LONDON