UIData Displayno dependencies

Sparkline

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

Weekly signups
94+123.8%

Customize

13 props
2
1200
260
64

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add sparkline

Writes 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.

example.tsx
1import { Sparkline } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Sparkline />6  );7}

Props

PropTypeDefault
values

Data points; numeric strings and comma separated strings are parsed, invalid entries are skipped.

Array<number | string> | string[42, 45, 41, 48, …, 89, 94]
label

Metric name shown in the card header and used in the accessible summary.

string'Weekly signups'
variant

KPI card with a big readout and trend delta, or just the line with a small readout chip.

'card' | 'bare''card'
accentColor

Line, gradient and dot color.

string'#6366f1'
curve

Catmull-Rom smoothed curve or straight segments.

'smooth' | 'linear''smooth'
showArea

Fill the area under the line with a fading gradient.

booleantrue
showDot

Show a pulsing dot on the last point.

booleantrue
strokeWidth

Line thickness in px.

number2
duration

Draw-in duration in ms.

number1200
width

Chart width in px (shrinks to fit narrow containers).

number260
height

Chart height in px.

number64
valuePrefix

Text placed before formatted values.

string''
valueSuffix

Text placed after formatted values.

string''
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/sparkline.tsx
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}