A tiny SVG trend line that draws itself in, fills a fading gradient area and lands on a pulsing end dot. Hover or arrow keys scrub a cursor with a live value readout, shown as a KPI card or a bare inline chip.
Preview
Customize
13 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add sparklineWrites components/ui/sparkline.tsx and components/styles/sparkline.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Sparkline } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Sparkline />6 );7}Props
Accessibility
The chart is one focusable role="img" whose aria-label summarizes the series (point count, first/last, low/high). Left/Right arrows, Home and End move a cursor through the points and a polite live region announces "Point n: value"; Escape and blur reset it. Focus is shown with a 2px outline. Under prefers-reduced-motion the line, area and end dot render immediately and the pulse loop is disabled.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/sparkline.module.css";5 6export interface SparklineProps {7 /** Data points (numbers, numeric strings, or a comma separated string) */8 values?: Array<number | string> | string;9 /** Metric name shown in the card header (also the accessible name) */10 label?: string;11 /** "card" wraps the line in a KPI card with a big readout; "bare" renders only the line and a small readout chip */12 variant?: "card" | "bare";13 /** Line, dot and gradient color */14 accentColor?: string;15 /** Line interpolation */16 curve?: "smooth" | "linear";17 /** Fill the area under the line with a fading gradient */18 showArea?: boolean;19 /** Show a pulsing dot on the last point */20 showDot?: boolean;21 /** Line thickness in px */22 strokeWidth?: number;23 /** Draw-in duration in ms */24 duration?: number;25 /** Chart width in px */26 width?: number;27 /** Chart height in px */28 height?: number;29 /** Text placed before formatted values */30 valuePrefix?: string;31 /** Text placed after formatted values */32 valueSuffix?: string;33 /** Optional className merged onto the root */34 className?: string;35 /** Optional inline styles merged onto the root */36 style?: React.CSSProperties;37}38 39const DEFAULT_VALUES = [42, 45, 41, 48, 52, 49, 55, 53, 58, 62, 57, 64, 61, 67, 72, 69, 75, 78, 74, 81, 86, 83, 89, 94];40 41function toNumbers(v: Array<number | string> | string): number[] {42 const arr = typeof v === "string" ? v.split(",") : v;43 return arr.map((x) => (typeof x === "number" ? x : parseFloat(String(x).trim()))).filter((n) => Number.isFinite(n));44}45 46function linePath(pts: Array<[number, number]>, smooth: boolean) {47 if (!pts.length) return "";48 let d = `M${pts[0][0]},${pts[0][1]}`;49 for (let i = 1; i < pts.length; i++) {50 const [x, y] = pts[i];51 if (!smooth) {52 d += `L${x},${y}`;53 continue;54 }55 const p0 = pts[i - 2] ?? pts[i - 1];56 const p1 = pts[i - 1];57 const p2 = pts[i + 1] ?? pts[i];58 const c1x = p1[0] + (x - p0[0]) / 6;59 const c1y = p1[1] + (y - p0[1]) / 6;60 const c2x = x - (p2[0] - p1[0]) / 6;61 const c2y = y - (p2[1] - p1[1]) / 6;62 d += `C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${x},${y}`;63 }64 return d;65}66 67export function Sparkline({68 values = DEFAULT_VALUES,69 label = "Weekly signups",70 variant = "card",71 accentColor = "#6366f1",72 curve = "smooth",73 showArea = true,74 showDot = true,75 strokeWidth = 2,76 duration = 1200,77 width = 260,78 height = 64,79 valuePrefix = "",80 valueSuffix = "",81 className = "",82 style,83}: SparklineProps) {84 const gid = `mf-sl-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;85 const [active, setActive] = React.useState<number | null>(null);86 const nums = toNumbers(values);87 const data = nums.length > 1 ? nums : nums.length === 1 ? [nums[0], nums[0]] : [0, 0];88 const n = data.length;89 const min = Math.min(...data);90 const max = Math.max(...data);91 const pad = Math.max(strokeWidth, 4) + 1;92 const range = max - min || 1;93 const pts = data.map((v, i): [number, number] => [94 +(pad + (i / (n - 1)) * (width - pad * 2)).toFixed(2),95 +(pad + (1 - (v - min) / range) * (height - pad * 2)).toFixed(2),96 ]);97 const d = linePath(pts, curve === "smooth");98 const area = `${d}L${pts[n - 1][0]},${height}L${pts[0][0]},${height}Z`;99 const last = data[n - 1];100 const first = data[0];101 const shown = active !== null ? data[active] : last;102 const delta = first !== 0 ? ((last - first) / Math.abs(first)) * 100 : 0;103 const fmt = (v: number) => `${valuePrefix}${v.toLocaleString("en-US")}${valueSuffix}`;104 const end = pts[n - 1];105 const ap = active !== null ? pts[active] : null;106 107 const pick = (e: React.PointerEvent<SVGSVGElement>) => {108 const r = e.currentTarget.getBoundingClientRect();109 const x = ((e.clientX - r.left) / Math.max(1, r.width)) * width;110 const i = Math.round(((x - pad) / Math.max(1, width - pad * 2)) * (n - 1));111 setActive(Math.max(0, Math.min(n - 1, i)));112 };113 114 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {115 const cur = active ?? n - 1;116 let next = -1;117 if (e.key === "ArrowRight") next = Math.min(n - 1, cur + 1);118 else if (e.key === "ArrowLeft") next = Math.max(0, cur - 1);119 else if (e.key === "Home") next = 0;120 else if (e.key === "End") next = n - 1;121 else if (e.key === "Escape") return setActive(null);122 if (next < 0) return;123 e.preventDefault();124 setActive(next);125 };126 127 const summary = `${label}: ${n} points from ${fmt(first)} to ${fmt(last)}, low ${fmt(min)}, high ${fmt(max)}`;128 129 return (130 <div131 className={`${styles.root} ${className}`.trim()}132 data-variant={variant}133 style={{ "--mf-sl-accent": accentColor, "--mf-sl-dur": `${Math.max(0, duration)}ms`, ...style } as React.CSSProperties}134 >135 {variant === "card" && (136 <div className={styles.head}>137 <span className={styles.label}>{label}</span>138 <div className={styles.readoutRow}>139 <span className={styles.big}>{fmt(shown)}</span>140 {active === null ? (141 <span className={styles.delta} data-trend={delta >= 0 ? "up" : "down"}>142 {delta >= 0 ? "+" : ""}143 {delta.toFixed(1)}%144 </span>145 ) : (146 <span className={styles.point}>147 Point {active + 1} of {n}148 </span>149 )}150 </div>151 </div>152 )}153 <div154 className={styles.chart}155 tabIndex={0}156 role="img"157 aria-label={`${summary}. Use arrow keys to read values.`}158 onKeyDown={onKeyDown}159 onBlur={() => setActive(null)}160 >161 <svg162 width={width}163 height={height}164 viewBox={`0 0 ${width} ${height}`}165 className={styles.svg}166 aria-hidden="true"167 onPointerMove={pick}168 onPointerDown={pick}169 onPointerLeave={() => setActive(null)}170 >171 <defs>172 <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">173 <stop offset="0%" stopColor={accentColor} stopOpacity="0.35" />174 <stop offset="100%" stopColor={accentColor} stopOpacity="0" />175 </linearGradient>176 </defs>177 {showArea && <path className={styles.area} d={area} fill={`url(#${gid})`} />}178 <path className={styles.line} d={d} pathLength={1} strokeWidth={strokeWidth} />179 {ap && (180 <g className={styles.cursor}>181 <line x1={ap[0]} x2={ap[0]} y1={0} y2={height} className={styles.guide} />182 <circle cx={ap[0]} cy={ap[1]} r={strokeWidth + 2.5} className={styles.hoverDot} />183 </g>184 )}185 {showDot && !ap && (186 <g className={styles.endDot}>187 <circle cx={end[0]} cy={end[1]} r={strokeWidth + 5} className={styles.pulse} />188 <circle cx={end[0]} cy={end[1]} r={strokeWidth + 1.5} className={styles.dot} />189 </g>190 )}191 </svg>192 {variant === "bare" && (193 <span className={styles.chip} data-active={active !== null ? "" : undefined} aria-hidden="true">194 {fmt(shown)}195 </span>196 )}197 </div>198 <span className={styles.srOnly} aria-live="polite">199 {active !== null ? `Point ${active + 1}: ${fmt(data[active])}` : ""}200 </span>201 </div>202 );203}