Web InnoventixFreeCode

Dashboard KPI Row

Original · free

row of KPI stat cards with trends

byWeb InnoventixReact + Tailwind
dashkpirowdashboards
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/dash-kpi-row.json
dash-kpi-row.tsx
"use client";

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

type Unit = "currency" | "count" | "percent" | "ms";
type RangeId = "7d" | "30d" | "90d";

type Period = {
  value: number;
  delta: number;
  note: string;
};

type Kpi = {
  id: string;
  label: string;
  short: string;
  unit: Unit;
  seed: number;
  lowerIsBetter: boolean;
  icon: ReactNode;
  data: Record<RangeId, Period>;
};

const RANGES: ReadonlyArray<{ id: RangeId; label: string; long: string; points: number }> = [
  { id: "7d", label: "7D", long: "last 7 days", points: 7 },
  { id: "30d", label: "30D", long: "last 30 days", points: 30 },
  { id: "90d", label: "90D", long: "last 90 days", points: 45 },
];

const IconRevenue = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
    <path d="M12 3v18" strokeLinecap="round" />
    <path d="M16.5 7.5A3.5 3.5 0 0 0 13 5h-2a3 3 0 0 0 0 6h2a3 3 0 0 1 0 6h-2a3.5 3.5 0 0 1-3.5-2.5" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
);

const IconWorkspace = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
    <rect x="3" y="3" width="7.5" height="7.5" rx="2" />
    <rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
    <rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
    <path d="M17.25 13.5v7.5M21 17.25h-7.5" strokeLinecap="round" />
  </svg>
);

const IconConversion = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
    <path d="M3 5h18l-6.75 7.6V20l-4.5-2.6v-4.8L3 5Z" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
);

const IconLatency = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
    <path d="M13 2 4.5 13.5H11L10 22l8.5-11.5H12L13 2Z" strokeLinecap="round" strokeLinejoin="round" />
  </svg>
);

const KPIS: ReadonlyArray<Kpi> = [
  {
    id: "revenue",
    label: "Net revenue",
    short: "Revenue",
    unit: "currency",
    seed: 1.4,
    lowerIsBetter: false,
    icon: IconRevenue,
    data: {
      "7d": {
        value: 48210,
        delta: 4.8,
        note: "Two annual upgrades landed on Tuesday and carried most of the week's lift. Weekend volume was flat, as usual.",
      },
      "30d": {
        value: 196400,
        delta: 12.4,
        note: "The Team → Scale upgrade path is doing the work: 61 accounts moved up, and expansion now outpaces new logos for the third month running.",
      },
      "90d": {
        value: 571900,
        delta: 26.1,
        note: "Growth is compounding off the March pricing change. Churned revenue held under $9k a month across the whole quarter.",
      },
    },
  },
  {
    id: "workspaces",
    label: "New workspaces",
    short: "Workspaces",
    unit: "count",
    seed: 5.9,
    lowerIsBetter: false,
    icon: IconWorkspace,
    data: {
      "7d": {
        value: 96,
        delta: -2.1,
        note: "A quiet week. Signups dipped Thursday when the docs search outage pushed the getting-started page to a 502 for 40 minutes.",
      },
      "30d": {
        value: 412,
        delta: 5.6,
        note: "Invite-a-teammate now accounts for 38% of new workspaces, up from 24% before the onboarding checklist shipped.",
      },
      "90d": {
        value: 1184,
        delta: 18.9,
        note: "Organic search is the fastest-growing source at +44% over the quarter, mostly from the integration comparison pages.",
      },
    },
  },
  {
    id: "conversion",
    label: "Checkout conversion",
    short: "Conversion",
    unit: "percent",
    seed: 9.2,
    lowerIsBetter: false,
    icon: IconConversion,
    data: {
      "7d": {
        value: 3.82,
        delta: 0.9,
        note: "Recovered after the card-decline copy was rewritten. Apple Pay sessions convert at nearly twice the rate of manual card entry.",
      },
      "30d": {
        value: 3.64,
        delta: -3.2,
        note: "The dip tracks the new tax-ID field on the billing step — 11% of EU sessions abandon there. Worth making it optional and validating later.",
      },
      "90d": {
        value: 3.41,
        delta: -1.4,
        note: "Broadly stable. The quarter's low came during the April trial-length test, which we rolled back after two weeks.",
      },
    },
  },
  {
    id: "latency",
    label: "p95 response time",
    short: "Latency",
    unit: "ms",
    seed: 13.6,
    lowerIsBetter: true,
    icon: IconLatency,
    data: {
      "7d": {
        value: 214,
        delta: -6.4,
        note: "Steady all week. The single spike to 380 ms was Sunday's index rebuild, which runs off-peak by design.",
      },
      "30d": {
        value: 228,
        delta: -18.3,
        note: "Connection pooling on the read replicas took roughly 50 ms off every list query. This is the cleanest win of the month.",
      },
      "90d": {
        value: 246,
        delta: -24.7,
        note: "Three straight months of improvement. The remaining tail is almost entirely the export endpoint, which still runs synchronously.",
      },
    },
  },
];

function shape(seed: number, len: number, drift: number): number[] {
  const raw: number[] = [];
  for (let i = 0; i < len; i += 1) {
    const t = len === 1 ? 1 : i / (len - 1);
    const wobble =
      0.055 * Math.sin(i * 1.9 + seed) +
      0.032 * Math.sin(i * 0.61 + seed * 1.3) +
      0.018 * Math.sin(i * 3.4 + seed * 0.7);
    raw.push(1 + drift * (t - 1) + wobble);
  }
  const last = raw[raw.length - 1];
  return raw.map((v) => v / last);
}

function buildSeries(seed: number, len: number, drift: number, end: number): number[] {
  return shape(seed, len, drift).map((v) => v * end);
}

function clampDrift(deltaPct: number): number {
  const d = deltaPct / 100;
  return Math.max(-0.34, Math.min(0.34, d));
}

function fmt(v: number, unit: Unit): string {
  if (unit === "currency") return `$${Math.round(v).toLocaleString("en-US")}`;
  if (unit === "count") return Math.round(v).toLocaleString("en-US");
  if (unit === "percent") return `${v.toFixed(2)}%`;
  return `${Math.round(v)} ms`;
}

function linePath(vals: number[], min: number, max: number): string {
  const span = max - min || 1;
  const pad = 3;
  const h = 32;
  return vals
    .map((v, i) => {
      const x = vals.length === 1 ? 0 : (i / (vals.length - 1)) * 100;
      const y = pad + (1 - (v - min) / span) * (h - pad * 2);
      return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
    })
    .join(" ");
}

function endPoint(vals: number[], min: number, max: number): { left: number; top: number } {
  const span = max - min || 1;
  const pad = 3;
  const h = 32;
  const v = vals[vals.length - 1];
  return { left: 100, top: ((pad + (1 - (v - min) / span) * (h - pad * 2)) / h) * 100 };
}

function useCountUp(target: number, enabled: boolean): number {
  const [display, setDisplay] = useState(target);
  const currentRef = useRef(target);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    if (!enabled) {
      currentRef.current = target;
      setDisplay(target);
      return;
    }
    const from = currentRef.current;
    if (from === target) return;
    const start = performance.now();
    const duration = 620;
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      const eased = 1 - Math.pow(1 - t, 3);
      const next = from + (target - from) * eased;
      currentRef.current = next;
      setDisplay(next);
      if (t < 1) rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, [target, enabled]);

  return display;
}

function Arrow({ up }: { up: boolean }) {
  return (
    <svg viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth={1.9} aria-hidden="true">
      <path d={up ? "M6 9.5V2.5M6 2.5 2.75 5.75M6 2.5l3.25 3.25" : "M6 2.5v7M6 9.5 2.75 6.25M6 9.5l3.25-3.25"} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export default function DashKpiRow() {
  const reduce = useReducedMotion();
  const uid = useId().replace(/:/g, "");
  const [range, setRange] = useState<RangeId>("30d");
  const [compare, setCompare] = useState(true);
  const [active, setActive] = useState<string>(KPIS[0].id);

  const rangeRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const rangeMeta = RANGES.find((r) => r.id === range) ?? RANGES[1];
  const points = rangeMeta.points;

  const chart = useCallback(
    (kpi: Kpi) => {
      const period = kpi.data[range];
      const drift = clampDrift(period.delta);
      const current = buildSeries(kpi.seed, points, drift, period.value);
      const prevEnd = period.value / (1 + period.delta / 100);
      const previous = buildSeries(kpi.seed + 3.7, points, drift * 0.55, prevEnd);
      const pool = compare ? current.concat(previous) : current;
      const min = Math.min(...pool);
      const max = Math.max(...pool);
      return { current, previous, min, max, period };
    },
    [range, points, compare],
  );

  const activeKpi = KPIS.find((k) => k.id === active) ?? KPIS[0];
  const activeChart = useMemo(() => chart(activeKpi), [chart, activeKpi]);

  const summary = useMemo(() => {
    const s = activeChart.current;
    const sum = s.reduce((a, b) => a + b, 0);
    return { peak: Math.max(...s), low: Math.min(...s), avg: sum / s.length };
  }, [activeChart]);

  function onRangeKey(e: KeyboardEvent<HTMLDivElement>) {
    const i = RANGES.findIndex((r) => r.id === range);
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % RANGES.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + RANGES.length) % RANGES.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = RANGES.length - 1;
    else return;
    e.preventDefault();
    setRange(RANGES[next].id);
    rangeRefs.current[next]?.focus();
  }

  function onTabKey(e: KeyboardEvent<HTMLDivElement>) {
    const i = KPIS.findIndex((k) => k.id === active);
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % KPIS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + KPIS.length) % KPIS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = KPIS.length - 1;
    else return;
    e.preventDefault();
    setActive(KPIS[next].id);
    tabRefs.current[next]?.focus();
  }

  const ring =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes dkr-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
        @keyframes dkr-fade { from { opacity: 0; } to { opacity: 1; } }
        @keyframes dkr-halo { 0% { transform: scale(1); opacity: .55; } 70%, 100% { transform: scale(2.6); opacity: 0; } }
        .dkr-line { stroke-dasharray: 1; animation: dkr-draw 900ms cubic-bezier(.22,1,.36,1) both; }
        .dkr-ghost { animation: dkr-fade 600ms ease-out 140ms both; }
        .dkr-area { animation: dkr-fade 700ms ease-out 220ms both; }
        .dkr-halo { animation: dkr-halo 2.6s cubic-bezier(.22,1,.36,1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .dkr-line, .dkr-ghost, .dkr-area, .dkr-halo { animation: none !important; }
          .dkr-line { stroke-dashoffset: 0 !important; }
          .dkr-ghost, .dkr-area { opacity: 1 !important; }
          .dkr-halo { opacity: 0 !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
              Overview
            </p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
              Product health
            </h2>
            <p className="mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
              Compared against the preceding period of equal length. Select a metric to read the breakdown.
            </p>
          </div>

          <div className="flex flex-wrap items-center gap-3">
            <div
              role="radiogroup"
              aria-label="Reporting range"
              onKeyDown={onRangeKey}
              className="inline-flex rounded-xl border border-slate-200 bg-white p-1 shadow-sm dark:border-slate-800 dark:bg-slate-900"
            >
              {RANGES.map((r, i) => {
                const on = r.id === range;
                return (
                  <button
                    key={r.id}
                    ref={(el) => {
                      rangeRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={on}
                    tabIndex={on ? 0 : -1}
                    onClick={() => setRange(r.id)}
                    className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${ring} ${
                      on
                        ? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
                        : "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
                    }`}
                  >
                    {r.label}
                    <span className="sr-only"> — {r.long}</span>
                  </button>
                );
              })}
            </div>

            <button
              type="button"
              role="switch"
              aria-checked={compare}
              onClick={() => setCompare((v) => !v)}
              className={`inline-flex items-center gap-2.5 rounded-xl border border-slate-200 bg-white py-2 pl-3 pr-3.5 text-xs font-semibold text-slate-700 shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 ${ring}`}
            >
              <span
                aria-hidden="true"
                className={`relative h-4 w-7 shrink-0 rounded-full transition-colors ${
                  compare ? "bg-indigo-600 dark:bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-[left] duration-200 ${
                    compare ? "left-3.5" : "left-0.5"
                  }`}
                />
              </span>
              Prior period
            </button>
          </div>
        </div>

        <div
          role="tablist"
          aria-label="Key metrics"
          aria-orientation="horizontal"
          onKeyDown={onTabKey}
          className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
        >
          {KPIS.map((kpi, i) => (
            <Card
              key={kpi.id}
              kpi={kpi}
              index={i}
              uid={uid}
              range={range}
              rangeLong={rangeMeta.long}
              compare={compare}
              selected={kpi.id === active}
              reduce={Boolean(reduce)}
              chart={chart(kpi)}
              ring={ring}
              onSelect={() => setActive(kpi.id)}
              tabRef={(el) => {
                tabRefs.current[i] = el;
              }}
            />
          ))}
        </div>

        <div
          role="tabpanel"
          id={`${uid}-panel-${activeKpi.id}`}
          aria-labelledby={`${uid}-tab-${activeKpi.id}`}
          tabIndex={0}
          className={`mt-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900 ${ring}`}
        >
          <div className="flex flex-col gap-6 lg:flex-row lg:items-start">
            <div className="lg:w-[46%]">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
                {activeKpi.label} · {rangeMeta.long}
              </h3>
              <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                {activeChart.period.note}
              </p>
              <dl className="mt-5 grid grid-cols-3 gap-3">
                {[
                  { k: "Peak", v: summary.peak },
                  { k: "Low", v: summary.low },
                  { k: "Average", v: summary.avg },
                ].map((s) => (
                  <div key={s.k} className="rounded-xl bg-slate-50 px-3 py-2.5 dark:bg-slate-800/60">
                    <dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                      {s.k}
                    </dt>
                    <dd className="mt-0.5 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
                      {fmt(s.v, activeKpi.unit)}
                    </dd>
                  </div>
                ))}
              </dl>
            </div>

            <div className="min-w-0 flex-1">
              <div className="mb-3 flex items-center gap-4 text-[11px] font-medium text-slate-500 dark:text-slate-400">
                <span className="inline-flex items-center gap-1.5">
                  <span aria-hidden="true" className="h-0.5 w-4 rounded-full bg-indigo-500" />
                  {rangeMeta.long}
                </span>
                {compare && (
                  <span className="inline-flex items-center gap-1.5">
                    <span
                      aria-hidden="true"
                      className="h-0.5 w-4 rounded-full bg-slate-300 dark:bg-slate-600"
                      style={{ backgroundImage: "repeating-linear-gradient(90deg,currentColor 0 3px,transparent 3px 6px)" }}
                    />
                    Prior period
                  </span>
                )}
              </div>
              <div className="relative h-40 w-full rounded-xl bg-slate-50 p-3 dark:bg-slate-800/50">
                <svg
                  viewBox="0 0 100 32"
                  preserveAspectRatio="none"
                  className="h-full w-full overflow-visible"
                  role="img"
                  aria-label={`${activeKpi.label} over the ${rangeMeta.long}: peak ${fmt(summary.peak, activeKpi.unit)}, low ${fmt(summary.low, activeKpi.unit)}, average ${fmt(summary.avg, activeKpi.unit)}, ending at ${fmt(activeChart.period.value, activeKpi.unit)}.`}
                >
                  {[8, 16, 24].map((y) => (
                    <line
                      key={y}
                      x1="0"
                      x2="100"
                      y1={y}
                      y2={y}
                      className="stroke-slate-200 dark:stroke-slate-700"
                      strokeWidth={1}
                      vectorEffect="non-scaling-stroke"
                    />
                  ))}
                  {compare && (
                    <path
                      key={`${activeKpi.id}-${range}-ghost`}
                      d={linePath(activeChart.previous, activeChart.min, activeChart.max)}
                      className="dkr-ghost fill-none stroke-slate-400/80 dark:stroke-slate-500"
                      strokeWidth={1.5}
                      strokeDasharray="3 3"
                      strokeLinecap="round"
                      vectorEffect="non-scaling-stroke"
                    />
                  )}
                  <path
                    key={`${activeKpi.id}-${range}-line`}
                    d={linePath(activeChart.current, activeChart.min, activeChart.max)}
                    className="dkr-line fill-none stroke-indigo-500 dark:stroke-indigo-400"
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    pathLength={1}
                    vectorEffect="non-scaling-stroke"
                  />
                </svg>
              </div>
            </div>
          </div>
        </div>

        <p aria-live="polite" className="sr-only">
          {`${activeKpi.label} selected. Showing the ${rangeMeta.long}${compare ? ", compared with the prior period" : ""}.`}
        </p>
      </div>
    </section>
  );
}

function Card({
  kpi,
  index,
  uid,
  range,
  rangeLong,
  compare,
  selected,
  reduce,
  chart,
  ring,
  onSelect,
  tabRef,
}: {
  kpi: Kpi;
  index: number;
  uid: string;
  range: RangeId;
  rangeLong: string;
  compare: boolean;
  selected: boolean;
  reduce: boolean;
  chart: { current: number[]; previous: number[]; min: number; max: number; period: Period };
  ring: string;
  onSelect: () => void;
  tabRef: (el: HTMLButtonElement | null) => void;
}) {
  const { current, previous, min, max, period } = chart;
  const up = period.delta >= 0;
  const good = kpi.lowerIsBetter ? !up : up;
  const shown = useCountUp(period.value, !reduce);
  const dot = endPoint(current, min, max);
  const gid = `${uid}-grad-${kpi.id}`;
  const area = `${linePath(current, min, max)} L100,32 L0,32 Z`;

  const tone = good
    ? "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25"
    : "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25";

  return (
    <motion.div
      role="presentation"
      initial={reduce ? false : { opacity: 0, y: 14 }}
      whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-60px" }}
      transition={{ duration: 0.45, ease: "easeOut", delay: reduce ? 0 : index * 0.07 }}
    >
      <button
        ref={tabRef}
        type="button"
        role="tab"
        id={`${uid}-tab-${kpi.id}`}
        aria-selected={selected}
        aria-controls={`${uid}-panel-${kpi.id}`}
        tabIndex={selected ? 0 : -1}
        onClick={onSelect}
        className={`group relative flex h-full w-full flex-col overflow-hidden rounded-2xl border bg-white p-5 text-left shadow-sm transition-colors dark:bg-slate-900 ${ring} ${
          selected
            ? "border-indigo-500/60 ring-1 ring-indigo-500/25 dark:border-indigo-400/60 dark:ring-indigo-400/25"
            : "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700"
        }`}
      >
        <span
          aria-hidden="true"
          className={`absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-400 transition-opacity ${
            selected ? "opacity-100" : "opacity-0 group-hover:opacity-50"
          }`}
        />

        <span className="flex items-start justify-between gap-3">
          <span className="flex items-center gap-2">
            <span
              aria-hidden="true"
              className={`grid h-7 w-7 place-items-center rounded-lg transition-colors ${
                selected
                  ? "bg-indigo-500/10 text-indigo-600 dark:bg-indigo-400/15 dark:text-indigo-300"
                  : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
              }`}
            >
              <span className="block h-4 w-4">{kpi.icon}</span>
            </span>
            <span className="text-xs font-medium text-slate-600 dark:text-slate-400">{kpi.label}</span>
          </span>
        </span>

        <span className="mt-4 block text-2xl font-semibold tabular-nums tracking-tight text-slate-900 dark:text-white">
          {fmt(shown, kpi.unit)}
        </span>

        <motion.span
          key={`${range}-${period.delta}`}
          initial={reduce ? false : { opacity: 0, y: -4 }}
          animate={reduce ? undefined : { opacity: 1, y: 0 }}
          transition={{ duration: 0.3, ease: "easeOut" }}
          className="mt-2 flex items-center gap-2"
        >
          <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums ring-1 ring-inset ${tone}`}>
            <Arrow up={up} />
            {`${up ? "+" : "−"}${Math.abs(period.delta).toFixed(1)}%`}
          </span>
          <span className="text-[11px] text-slate-500 dark:text-slate-500">vs prior</span>
          <span className="sr-only">
            {`${Math.abs(period.delta).toFixed(1)} percent ${up ? "increase" : "decrease"} versus the preceding ${rangeLong}, which is ${good ? "an improvement" : "a decline"}.`}
          </span>
        </motion.span>

        <span className="relative mt-5 block h-9 w-full">
          <svg viewBox="0 0 100 32" preserveAspectRatio="none" className="h-full w-full" aria-hidden="true" focusable="false">
            <defs>
              <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" className="text-indigo-500 dark:text-indigo-400" stopColor="currentColor" stopOpacity="0.28" />
                <stop offset="100%" className="text-indigo-500 dark:text-indigo-400" stopColor="currentColor" stopOpacity="0" />
              </linearGradient>
            </defs>
            {compare && (
              <path
                key={`${kpi.id}-${range}-c-ghost`}
                d={linePath(previous, min, max)}
                className="dkr-ghost fill-none stroke-slate-300 dark:stroke-slate-600"
                strokeWidth={1.5}
                strokeDasharray="3 3"
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
              />
            )}
            <path key={`${kpi.id}-${range}-c-area`} d={area} className="dkr-area" fill={`url(#${gid})`} />
            <path
              key={`${kpi.id}-${range}-c-line`}
              d={linePath(current, min, max)}
              className="dkr-line fill-none stroke-indigo-500 dark:stroke-indigo-400"
              strokeWidth={1.8}
              strokeLinecap="round"
              strokeLinejoin="round"
              pathLength={1}
              vectorEffect="non-scaling-stroke"
            />
          </svg>
          <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 bg-indigo-500 dark:bg-indigo-400"
            style={{ left: `${dot.left}%`, top: `${dot.top}%` }}
          >
            <span className="dkr-halo absolute inset-0 rounded-full bg-indigo-500 dark:bg-indigo-400" />
          </span>
        </span>
      </button>
    </motion.div>
  );
}

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 →