Web InnoventixFreeCode

Chart Sparkline

Original · free

inline sparklines in stat rows

byWeb InnoventixReact + Tailwind
chartsparklinecharts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/chart-sparkline.json
chart-sparkline.tsx
"use client";

import {
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type RangeKey = "7D" | "30D" | "90D";
type Unit = "currency" | "number" | "percent" | "duration";

interface MetricDef {
  id: string;
  label: string;
  hint: string;
  unit: Unit;
  seed: number;
  base: number;
  volatility: number;
  trend: number;
  goodWhenDown: boolean;
}

const METRICS: MetricDef[] = [
  {
    id: "mrr",
    label: "Recurring revenue",
    hint: "Net new plus expansion",
    unit: "currency",
    seed: 1971,
    base: 46200,
    volatility: 1500,
    trend: 165,
    goodWhenDown: false,
  },
  {
    id: "accounts",
    label: "Active accounts",
    hint: "Workspaces active this week",
    unit: "number",
    seed: 5503,
    base: 1180,
    volatility: 66,
    trend: 6.4,
    goodWhenDown: false,
  },
  {
    id: "conversion",
    label: "Trial conversion",
    hint: "Signup to paid plan",
    unit: "percent",
    seed: 8123,
    base: 3.1,
    volatility: 0.42,
    trend: 0.018,
    goodWhenDown: false,
  },
  {
    id: "session",
    label: "Median session",
    hint: "Time in app per visit",
    unit: "duration",
    seed: 3390,
    base: 236,
    volatility: 24,
    trend: 1.05,
    goodWhenDown: false,
  },
  {
    id: "bounce",
    label: "Bounce rate",
    hint: "Left within ten seconds",
    unit: "percent",
    seed: 6642,
    base: 44,
    volatility: 2.2,
    trend: -0.11,
    goodWhenDown: true,
  },
  {
    id: "backlog",
    label: "Support backlog",
    hint: "Tickets open over a day",
    unit: "number",
    seed: 9007,
    base: 72,
    volatility: 8,
    trend: -0.42,
    goodWhenDown: true,
  },
];

const RANGE_OPTIONS: { value: RangeKey; label: string }[] = [
  { value: "7D", label: "7D" },
  { value: "30D", label: "30D" },
  { value: "90D", label: "90D" },
];

const RANGE_COUNT: Record<RangeKey, number> = { "7D": 7, "30D": 30, "90D": 90 };

const MASTER_LEN = 90;
const W = 300;
const H = 64;
const PAD = 0.16;

// Deterministic Lehmer PRNG so server and client render identical series.
function buildSeries(def: MetricDef, len: number): number[] {
  let s = def.seed % 2147483647;
  if (s <= 0) s += 2147483646;
  const next = (): number => {
    s = (s * 16807) % 2147483647;
    return (s - 1) / 2147483646;
  };
  const out: number[] = [];
  let v = def.base;
  for (let i = 0; i < len; i += 1) {
    v = v + (next() - 0.5) * def.volatility + def.trend;
    if (v < 0) v = Math.abs(v) * 0.5 + 1;
    out.push(v);
  }
  return out;
}

function formatValue(unit: Unit, v: number): string {
  switch (unit) {
    case "currency":
      return `$${Math.round(v).toLocaleString("en-US")}`;
    case "number":
      return Math.round(v).toLocaleString("en-US");
    case "percent":
      return `${v.toFixed(1)}%`;
    case "duration": {
      const total = Math.round(v);
      const m = Math.floor(total / 60);
      const sec = total % 60;
      return `${m}m ${sec.toString().padStart(2, "0")}s`;
    }
    default:
      return String(v);
  }
}

function whenLabel(daysAgo: number): string {
  if (daysAgo <= 0) return "Today";
  if (daysAgo === 1) return "Yesterday";
  return `${daysAgo} days ago`;
}

function TrendArrow({ up }: { up: boolean }) {
  return (
    <svg
      viewBox="0 0 12 12"
      className="h-3 w-3"
      aria-hidden="true"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {up ? (
        <path d="M6 9.5V2.5M6 2.5L3 5.5M6 2.5L9 5.5" />
      ) : (
        <path d="M6 2.5V9.5M6 9.5L3 6.5M6 9.5L9 6.5" />
      )}
    </svg>
  );
}

interface AccentTheme {
  text: string;
  dot: string;
  ring: string;
  cross: string;
  badge: string;
}

const ACCENT_GOOD: AccentTheme = {
  text: "text-emerald-500 dark:text-emerald-400",
  dot: "bg-emerald-500 dark:bg-emerald-400",
  ring: "bg-emerald-500/40 dark:bg-emerald-400/40",
  cross: "bg-emerald-400/50 dark:bg-emerald-400/40",
  badge:
    "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
};

const ACCENT_BAD: AccentTheme = {
  text: "text-rose-500 dark:text-rose-400",
  dot: "bg-rose-500 dark:bg-rose-400",
  ring: "bg-rose-500/40 dark:bg-rose-400/40",
  cross: "bg-rose-400/50 dark:bg-rose-400/40",
  badge:
    "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
};

interface SegProps {
  value: RangeKey;
  onChange: (v: RangeKey) => void;
}

function SegmentedControl({ value, onChange }: SegProps) {
  const reduce = useReducedMotion();
  const pillId = useId();
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const idx = RANGE_OPTIONS.findIndex((o) => o.value === value);

  const move = (to: number) => {
    const clamped = (to + RANGE_OPTIONS.length) % RANGE_OPTIONS.length;
    onChange(RANGE_OPTIONS[clamped].value);
    refs.current[clamped]?.focus();
  };

  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      move(idx + 1);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      move(idx - 1);
    } else if (e.key === "Home") {
      e.preventDefault();
      move(0);
    } else if (e.key === "End") {
      e.preventDefault();
      move(RANGE_OPTIONS.length - 1);
    }
  };

  return (
    <div
      role="radiogroup"
      aria-label="Time range"
      className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800/80"
    >
      {RANGE_OPTIONS.map((o, i) => {
        const selected = o.value === value;
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={selected}
            aria-label={`${o.label}, last ${RANGE_COUNT[o.value]} days`}
            tabIndex={selected ? 0 : -1}
            onClick={() => onChange(o.value)}
            onKeyDown={onKeyDown}
            className={`relative rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-800 ${
              selected
                ? "text-slate-900 dark:text-white"
                : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
            }`}
          >
            {selected && (
              <motion.span
                layoutId={`spk-seg-active-${pillId}`}
                aria-hidden="true"
                className="absolute inset-0 rounded-md bg-white shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-950 dark:ring-white/10"
                transition={
                  reduce
                    ? { duration: 0 }
                    : { type: "spring", stiffness: 420, damping: 34 }
                }
              />
            )}
            <span className="relative z-10">{o.label}</span>
          </button>
        );
      })}
    </div>
  );
}

interface StatRowProps {
  def: MetricDef;
  series: number[];
  range: RangeKey;
  index: number;
  reduce: boolean | null;
}

function StatRow({ def, series, range, index, reduce }: StatRowProps) {
  const gradId = useId();
  const wrapRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState<number | null>(null);
  const [focused, setFocused] = useState(false);

  const view = useMemo(() => {
    const n = series.length;
    const min = Math.min(...series);
    const max = Math.max(...series);
    const span = max - min || 1;
    const pts = series.map((v, i) => {
      const xf = n === 1 ? 0 : i / (n - 1);
      const yn = (v - min) / span;
      const yf = 1 - (PAD + yn * (1 - 2 * PAD));
      return { xf, yf, v };
    });
    const line = pts
      .map(
        (p, i) =>
          `${i === 0 ? "M" : "L"}${(p.xf * W).toFixed(2)} ${(p.yf * H).toFixed(2)}`,
      )
      .join(" ");
    const area = `${line} L${W} ${H} L0 ${H} Z`;
    return { n, pts, line, area };
  }, [series]);

  const first = series[0];
  const last = series[series.length - 1];
  const pct = first === 0 ? 0 : ((last - first) / Math.abs(first)) * 100;
  const up = pct >= 0;
  const good = up ? !def.goodWhenDown : def.goodWhenDown;
  const accent = good ? ACCENT_GOOD : ACCENT_BAD;

  const currentText = formatValue(def.unit, last);
  const deltaText = `${up ? "+" : "−"}${Math.abs(pct).toFixed(1)}%`;
  const lastPoint = view.pts[view.pts.length - 1];

  const setFromClientX = (clientX: number) => {
    const el = wrapRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    if (rect.width === 0) return;
    const frac = (clientX - rect.left) / rect.width;
    const i = Math.round(frac * (view.n - 1));
    setActive(Math.max(0, Math.min(view.n - 1, i)));
  };

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    setFromClientX(e.clientX);
  };
  // Touch taps fire pointerdown/up with no preceding move, so hover alone
  // would leave the advertised "tap" interaction dead on mobile.
  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    setFromClientX(e.clientX);
  };
  const onPointerLeave = () => {
    if (!focused) setActive(null);
  };
  const onFocus = () => {
    setFocused(true);
    setActive((p) => (p === null ? view.n - 1 : p));
  };
  const onBlur = () => {
    setFocused(false);
    setActive(null);
  };
  const step = (delta: number) => {
    setActive((p) => {
      const from = p === null ? view.n - 1 : Math.min(view.n - 1, Math.max(0, p));
      return Math.max(0, Math.min(view.n - 1, from + delta));
    });
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "ArrowRight" || e.key === "ArrowUp") {
      e.preventDefault();
      step(1);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
      e.preventDefault();
      step(-1);
    } else if (e.key === "Home") {
      e.preventDefault();
      setActive(0);
    } else if (e.key === "End") {
      e.preventDefault();
      setActive(view.n - 1);
    } else if (e.key === "Escape") {
      setActive(null);
      wrapRef.current?.blur();
    }
  };

  // `active` indexes `series`, which shrinks when the range changes, so a stale
  // index from a wider range must be clamped back into bounds before use.
  const activeIndex =
    active === null ? null : Math.max(0, Math.min(view.n - 1, active));
  const activePoint = activeIndex === null ? null : view.pts[activeIndex];
  const activeDaysAgo = activeIndex === null ? 0 : view.n - 1 - activeIndex;
  const tipLeft = activePoint
    ? Math.min(0.9, Math.max(0.1, activePoint.xf)) * 100
    : 0;
  const liveText =
    activePoint === null
      ? ""
      : `${def.label}, ${whenLabel(activeDaysAgo)}: ${formatValue(def.unit, activePoint.v)}`;

  const groupLabel = `${def.label} trend over the last ${view.n} days. Currently ${currentText}, ${
    up ? "up" : "down"
  } ${Math.abs(pct).toFixed(1)} percent versus ${view.n} days ago. Use the arrow keys to inspect each day.`;

  return (
    <motion.li
      initial={reduce ? false : { opacity: 0, y: 12 }}
      whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-40px" }}
      transition={{ duration: 0.4, delay: index * 0.05, ease: "easeOut" }}
      className="flex flex-wrap items-center gap-x-4 gap-y-3 rounded-lg px-2 py-4 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40"
    >
      {/* Label */}
      <div className="w-40 shrink-0">
        <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
          {def.label}
        </p>
        <p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
          {def.hint}
        </p>
      </div>

      {/* Value + delta */}
      <div className="order-2 ml-auto flex items-center justify-end gap-3 sm:order-3 sm:ml-0 sm:w-40">
        <span className="text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100">
          {currentText}
        </span>
        <span
          role="img"
          aria-label={`${up ? "Up" : "Down"} ${Math.abs(pct).toFixed(1)} percent, ${
            good ? "improving" : "needs attention"
          }`}
          className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ${accent.badge}`}
        >
          <TrendArrow up={up} />
          {deltaText}
        </span>
      </div>

      {/* Sparkline */}
      <div className="relative order-3 w-full min-w-0 sm:order-2 sm:w-auto sm:flex-1">
        <div
          ref={wrapRef}
          role="group"
          tabIndex={0}
          aria-label={groupLabel}
          aria-roledescription="interactive sparkline"
          onPointerMove={onPointerMove}
          onPointerDown={onPointerDown}
          onPointerLeave={onPointerLeave}
          onFocus={onFocus}
          onBlur={onBlur}
          onKeyDown={onKeyDown}
          className={`relative h-14 w-full cursor-crosshair touch-pan-y rounded-md outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900 ${accent.text}`}
        >
          <svg
            viewBox={`0 0 ${W} ${H}`}
            preserveAspectRatio="none"
            className="h-full w-full overflow-visible"
            aria-hidden="true"
          >
            <defs>
              <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="currentColor" stopOpacity={0.26} />
                <stop offset="100%" stopColor="currentColor" stopOpacity={0} />
              </linearGradient>
            </defs>
            <motion.g
              key={`${def.id}-${range}`}
              initial={reduce ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              transition={{ duration: reduce ? 0 : 0.5, ease: "easeOut" }}
            >
              <path d={view.area} fill={`url(#${gradId})`} stroke="none" />
              <path
                d={view.line}
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
            </motion.g>
          </svg>

          {/* Resting marker at the latest point */}
          <span
            aria-hidden="true"
            className={`pointer-events-none absolute h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full ${accent.dot} ${
              activePoint ? "opacity-0" : "opacity-100"
            }`}
            style={{ left: "100%", top: `${lastPoint.yf * 100}%` }}
          />

          {/* Active crosshair + marker */}
          {activePoint && (
            <>
              <span
                aria-hidden="true"
                className={`pointer-events-none absolute inset-y-1 w-px ${accent.cross}`}
                style={{ left: `${activePoint.xf * 100}%` }}
              />
              {!reduce && (
                <span
                  aria-hidden="true"
                  className={`pointer-events-none absolute h-3 w-3 rounded-full spk-ping ${accent.ring}`}
                  style={{
                    left: `${activePoint.xf * 100}%`,
                    top: `${activePoint.yf * 100}%`,
                  }}
                />
              )}
              <span
                aria-hidden="true"
                className={`pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm dark:border-slate-900 ${accent.dot}`}
                style={{
                  left: `${activePoint.xf * 100}%`,
                  top: `${activePoint.yf * 100}%`,
                }}
              />
            </>
          )}
        </div>

        {/* Tooltip */}
        {activePoint && (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute top-0 z-20 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-slate-900 px-2 py-1 text-[11px] font-semibold text-white shadow-lg dark:bg-slate-100 dark:text-slate-900"
            style={{ left: `${tipLeft}%` }}
          >
            <span className="tabular-nums">
              {formatValue(def.unit, activePoint.v)}
            </span>
            <span className="ml-1.5 font-medium text-slate-400 dark:text-slate-500">
              {whenLabel(activeDaysAgo)}
            </span>
          </div>
        )}

        <span className="sr-only" role="status" aria-live="polite">
          {liveText}
        </span>
      </div>
    </motion.li>
  );
}

export default function ChartSparkline() {
  const reduce = useReducedMotion();
  const [range, setRange] = useState<RangeKey>("30D");

  const masters = useMemo(() => {
    const m: Record<string, number[]> = {};
    for (const def of METRICS) m[def.id] = buildSeries(def, MASTER_LEN);
    return m;
  }, []);

  const count = RANGE_COUNT[range];

  return (
    <section className="relative w-full bg-white px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes spk-ping {
          0%   { opacity: 0.55; transform: translate(-50%, -50%) scale(0.55); }
          100% { opacity: 0;    transform: translate(-50%, -50%) scale(2.1); }
        }
        .spk-ping { animation: spk-ping 1.5s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .spk-ping { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-4xl">
        <div className="rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900/50">
          {/* Header */}
          <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
            <div>
              <h2 className="text-lg font-semibold tracking-tight text-slate-900 sm:text-xl dark:text-white">
                Product analytics
              </h2>
              <p className="mt-1 max-w-md text-sm text-slate-500 dark:text-slate-400">
                Trailing window against the prior period. Hover, tap, or focus any
                trend line to inspect an individual day.
              </p>
              <div className="mt-3 flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
                <span className="inline-flex items-center gap-1.5">
                  <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
                  Improving
                </span>
                <span className="inline-flex items-center gap-1.5">
                  <span className="h-2 w-2 rounded-full bg-rose-500" aria-hidden="true" />
                  Needs attention
                </span>
              </div>
            </div>
            <SegmentedControl value={range} onChange={setRange} />
          </div>

          {/* Stat rows */}
          <ul className="mt-6 divide-y divide-slate-100 dark:divide-slate-800/80">
            {METRICS.map((def, i) => (
              <StatRow
                key={def.id}
                def={def}
                series={masters[def.id].slice(MASTER_LEN - count)}
                range={range}
                index={i}
                reduce={reduce}
              />
            ))}
          </ul>

          <p className="mt-6 border-t border-slate-100 pt-4 text-xs text-slate-400 dark:border-slate-800 dark:text-slate-500">
            Sample data shown for demonstration. Every figure recomputes when you
            change the time range above.
          </p>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →