Fazekit

Code

"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { cn } from "@/lib/utils";

export type KpiPeriod = {
  id: string;
  /** Short selector label, e.g. "7D". */
  label: string;
  /** Longer comparison text, e.g. "vs previous 7 days". */
  comparison?: string;
  value: number;
  previous: number;
  series: number[];
  /** Point labels for the sparkline tooltip (same length as series). */
  pointLabels?: string[];
};

export type SparklineKpiProps = {
  title: string;
  periods: KpiPeriod[];
  defaultPeriod?: string;
  onPeriodChange?: (id: string) => void;
  valueFormatter?: (v: number) => string;
  /** Lower is better (e.g. churn, latency): a decrease is shown as positive. */
  invert?: boolean;
  /** Line colour; defaults to the theme primary. */
  color?: string;
  icon?: React.ReactNode;
  className?: string;
};

const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });

function useElementWidth<T extends HTMLElement>() {
  const ref = React.useRef<T>(null);
  const [width, setWidth] = React.useState(0);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return [ref, width] as const;
}

/** Eased count toward `target` whenever it changes. */
function useCountTo(target: number, ms: number, skip: boolean) {
  const [v, setV] = React.useState(skip ? target : 0);
  const cur = React.useRef(skip ? target : 0);
  React.useEffect(() => {
    if (skip) {
      cur.current = target;
      return;
    }
    const from = cur.current;
    let raf = 0;
    const t0 = performance.now();
    const tick = (t: number) => {
      const k = Math.min(1, (t - t0) / ms);
      const next = from + (target - from) * (1 - Math.pow(1 - k, 4));
      cur.current = next;
      setV(next);
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [target, ms, skip]);
  return skip ? target : v;
}

function smooth(pts: [number, number][]) {
  if (pts.length < 2) return "";
  let d = `M${pts[0][0]},${pts[0][1]}`;
  for (let i = 1; i < pts.length; i++) {
    const [x0, y0] = pts[i - 1];
    const [x1, y1] = pts[i];
    const cx = (x0 + x1) / 2;
    d += `C${cx},${y0},${cx},${y1},${x1},${y1}`;
  }
  return d;
}

const H = 64;

export function SparklineKpi({
  title,
  periods,
  defaultPeriod,
  onPeriodChange,
  valueFormatter = (v) => compact.format(v),
  invert = false,
  color = "var(--primary)",
  icon,
  className,
}: SparklineKpiProps) {
  const reduce = !!useReducedMotion();
  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [periodId, setPeriodId] = React.useState(defaultPeriod ?? periods[0]?.id);
  const period = periods.find((p) => p.id === periodId) ?? periods[0];
  const [ref, width] = useElementWidth<HTMLDivElement>();
  const [active, setActive] = React.useState<number | null>(null);
  const shown = useCountTo(period.value, 700, reduce);

  const change = period.previous === 0 ? 0 : (period.value - period.previous) / Math.abs(period.previous);
  const good = change === 0 ? null : invert ? change < 0 : change > 0;
  const DeltaIcon = change === 0 ? Minus : change > 0 ? ArrowUpRight : ArrowDownRight;

  const s = period.series;
  const min = Math.min(...s);
  const max = Math.max(...s);
  const x = (i: number) => (s.length <= 1 ? width / 2 : (i / (s.length - 1)) * (width - 8) + 4);
  const y = (v: number) => 6 + (1 - (v - min) / (max - min || 1)) * (H - 12);
  const pts = s.map((v, i) => [x(i), y(v)] as [number, number]);
  const line = smooth(pts);
  const area = pts.length ? `${line}L${pts[pts.length - 1][0]},${H}L${pts[0][0]},${H}Z` : "";

  const select = (id: string) => {
    setPeriodId(id);
    setActive(null);
    onPeriodChange?.(id);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? s.length - 1;
    let next: number | null = cur;
    if (e.key === "ArrowRight") next = Math.min(s.length - 1, cur + 1);
    else if (e.key === "ArrowLeft") next = Math.max(0, cur - 1);
    else if (e.key === "Escape") next = null;
    else return;
    e.preventDefault();
    setActive(next);
  };

  const pointLabel = (i: number) => period.pointLabels?.[i] ?? `Point ${i + 1}`;

  return (
    <section aria-label={title} className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", className)}>
      <div className="flex items-start justify-between gap-3">
        <div className="flex min-w-0 items-center gap-2">
          {icon && <span className="grid size-7 shrink-0 place-items-center rounded-md border bg-muted/50 text-muted-foreground [&>svg]:size-3.5">{icon}</span>}
          <h3 className="truncate text-sm font-medium text-muted-foreground">{title}</h3>
        </div>
        {periods.length > 1 && (
          <div role="radiogroup" aria-label="Period" className="flex shrink-0 rounded-md bg-muted p-0.5 text-[11px] font-medium">
            {periods.map((p) => (
              <button
                key={p.id}
                type="button"
                role="radio"
                aria-checked={p.id === period.id}
                onClick={() => select(p.id)}
                className="relative rounded px-1.5 py-0.5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                {p.id === period.id && (
                  <motion.span
                    layoutId={`${uid}-pill`}
                    className="absolute inset-0 rounded bg-background shadow-xs"
                    transition={{ type: "spring", stiffness: 500, damping: 38 }}
                  />
                )}
                <span className={cn("relative", p.id === period.id ? "text-foreground" : "text-muted-foreground")}>{p.label}</span>
              </button>
            ))}
          </div>
        )}
      </div>

      <div className="mt-3 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
        <p className="text-3xl font-semibold tracking-tight tabular-nums">
          {valueFormatter(shown)}
        </p>
        <span
          className={cn(
            "inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
            good === null && "bg-muted text-muted-foreground",
            good === true && "bg-emerald-500/12 text-emerald-700 dark:text-emerald-400",
            good === false && "bg-rose-500/12 text-rose-700 dark:text-rose-400",
          )}
        >
          <DeltaIcon className="size-3" aria-hidden />
          <span className="sr-only">{change > 0 ? "Up" : change < 0 ? "Down" : "Unchanged"}</span>
          {Math.abs(change * 100).toFixed(1)}%
        </span>
      </div>
      <p className="mt-0.5 h-4 truncate text-xs text-muted-foreground" aria-hidden={active !== null}>
        {active === null ? (
          <>
            {period.comparison ?? "vs previous period"} · {valueFormatter(period.previous)}
          </>
        ) : (
          <>
            {pointLabel(active)} · <span className="font-medium text-foreground tabular-nums">{valueFormatter(s[active])}</span>
          </>
        )}
      </p>

      <div
        ref={ref}
        tabIndex={0}
        role="group"
        aria-label={`${title} trend. Use arrow keys to inspect.`}
        onKeyDown={onKeyDown}
        onBlur={() => setActive(null)}
        className="relative mt-4 w-full rounded outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
        style={{ height: H }}
      >
        {width > 0 && (
          <svg
            width={width}
            height={H}
            aria-hidden
            className="block overflow-visible"
            onPointerMove={(e) => {
              const r = e.currentTarget.getBoundingClientRect();
              const i = Math.round(((e.clientX - r.left - 4) / Math.max(1, width - 8)) * (s.length - 1));
              setActive(Math.max(0, Math.min(s.length - 1, i)));
            }}
            onPointerLeave={() => setActive(null)}
          >
            <defs>
              <linearGradient id={`${uid}-fill`} x1="0" x2="0" y1="0" y2="1">
                <stop offset="0%" style={{ stopColor: color, stopOpacity: 0.24 }} />
                <stop offset="100%" style={{ stopColor: color, stopOpacity: 0 }} />
              </linearGradient>
            </defs>
            <motion.path
              key={`a-${period.id}-${width}`}
              d={area}
              fill={`url(#${uid}-fill)`}
              initial={reduce ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              transition={{ duration: 0.6, delay: 0.35 }}
            />
            <motion.path
              key={`l-${period.id}-${width}`}
              d={line}
              fill="none"
              stroke={color}
              strokeWidth={2}
              strokeLinecap="round"
              initial={reduce ? false : { pathLength: 0 }}
              animate={{ pathLength: 1 }}
              transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
            />
            {active !== null && (
              <g pointerEvents="none">
                <line x1={x(active)} x2={x(active)} y1={0} y2={H} className="stroke-muted-foreground/40" strokeDasharray="2 3" />
                <circle cx={x(active)} cy={y(s[active])} r={4} fill={color} className="stroke-card" strokeWidth={2} />
              </g>
            )}
            {active === null && pts.length > 0 && (
              <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r={3} fill={color} className="stroke-card" strokeWidth={2} />
            )}
          </svg>
        )}
        <p className="sr-only" aria-live="polite">
          {active !== null ? `${pointLabel(active)}: ${valueFormatter(s[active])}` : ""}
        </p>
      </div>
    </section>
  );
}

More in Data Display

View all →