Web InnoventixFreeCode

Chart Stat Combo

Original · free

stat cards each with a mini chart

byWeb InnoventixReact + Tailwind
chartstatcombocharts
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-stat-combo.json
chart-stat-combo.tsx
"use client";

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

/* ------------------------------------------------------------------ */
/* Types                                                               */
/* ------------------------------------------------------------------ */

type RangeKey = "7d" | "30d" | "90d";
type ViewKey = "chart" | "table";
type Unit = "currency" | "count" | "percent";
type TrendKind = "area" | "bars" | "line";
type IconName = "revenue" | "signups" | "conversion" | "goal";

interface TrendMetric {
  type: "trend";
  id: string;
  label: string;
  short: string;
  unit: Unit;
  kind: TrendKind;
  icon: IconName;
  caption: string;
  series: Record<RangeKey, number[]>;
}

interface RingMetric {
  type: "ring";
  id: string;
  label: string;
  short: string;
  icon: IconName;
  value: number;
  goal: number;
  deltaPct: number;
  caption: string;
}

type Metric = TrendMetric | RingMetric;

interface Pt {
  x: number;
  y: number;
}

/* ------------------------------------------------------------------ */
/* Data — one shared time axis, so every card is scoped by one filter  */
/* ------------------------------------------------------------------ */

const RANGE_LABELS: Record<RangeKey, string[]> = {
  "7d": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
  "30d": [
    "Jun 20",
    "Jun 23",
    "Jun 26",
    "Jun 29",
    "Jul 2",
    "Jul 5",
    "Jul 8",
    "Jul 11",
    "Jul 14",
    "Jul 17",
  ],
  "90d": [
    "Apr 24",
    "May 1",
    "May 8",
    "May 15",
    "May 22",
    "May 29",
    "Jun 5",
    "Jun 12",
    "Jun 19",
    "Jun 26",
    "Jul 3",
    "Jul 10",
  ],
};

const RANGE_TEXT: Record<RangeKey, string> = {
  "7d": "the last 7 days",
  "30d": "the last 30 days",
  "90d": "the last 90 days",
};

const METRICS: Metric[] = [
  {
    type: "trend",
    id: "revenue",
    label: "Net revenue",
    short: "Revenue",
    unit: "currency",
    kind: "area",
    icon: "revenue",
    caption: "Billed, minus refunds and credits.",
    series: {
      "7d": [38200, 41000, 39800, 43600, 45200, 44100, 48200],
      "30d": [31000, 33500, 32800, 35900, 37200, 40100, 39400, 42800, 45600, 48200],
      "90d": [
        22000, 24500, 23800, 27100, 29900, 31200, 34000, 36800, 39900, 42500, 45300,
        48200,
      ],
    },
  },
  {
    type: "trend",
    id: "signups",
    label: "New signups",
    short: "Signups",
    unit: "count",
    kind: "bars",
    icon: "signups",
    caption: "Verified accounts, bots excluded.",
    series: {
      "7d": [120, 142, 131, 158, 149, 171, 183],
      "30d": [96, 110, 118, 131, 127, 142, 150, 163, 171, 183],
      "90d": [64, 78, 85, 92, 101, 110, 119, 131, 144, 158, 170, 183],
    },
  },
  {
    type: "trend",
    id: "conversion",
    label: "Trial conversion",
    short: "Conv. rate",
    unit: "percent",
    kind: "line",
    icon: "conversion",
    caption: "Trials that became paid plans.",
    series: {
      "7d": [3.1, 3.3, 3.2, 3.5, 3.6, 3.5, 3.8],
      "30d": [2.8, 2.9, 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.7, 3.8],
      "90d": [2.3, 2.4, 2.5, 2.6, 2.8, 2.9, 3.0, 3.2, 3.4, 3.5, 3.7, 3.8],
    },
  },
  {
    type: "ring",
    id: "goal",
    label: "Quarter target",
    short: "Q3 target",
    icon: "goal",
    value: 312000,
    goal: 400000,
    deltaPct: 14.2,
    caption: "Quarter to date — not scoped by the range filter.",
  },
];

/* ------------------------------------------------------------------ */
/* Geometry + formatting                                               */
/* ------------------------------------------------------------------ */

const H = 96;
const PAD_X = 10;
const PAD_T = 16;
const PAD_B = 18;
const BASELINE = H - PAD_B;

function compact(n: number): string {
  const abs = Math.abs(n);
  if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
  if (abs >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
  return String(Math.round(n));
}

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

function signed(delta: number): string {
  return `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`;
}

/** Smooth cubic path through points (midpoint control handles). */
function buildSmoothPath(pts: Pt[]): string {
  if (pts.length === 0) return "";
  if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
  let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[i];
    const p1 = pts[i + 1];
    const cx = (p0.x + p1.x) / 2;
    d += ` C ${cx.toFixed(2)} ${p0.y.toFixed(2)}, ${cx.toFixed(2)} ${p1.y.toFixed(
      2,
    )}, ${p1.x.toFixed(2)} ${p1.y.toFixed(2)}`;
  }
  return d;
}

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];

/* ------------------------------------------------------------------ */
/* Icons — inline, no icon library                                     */
/* ------------------------------------------------------------------ */

function MetricIcon({ name }: { name: IconName }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
      className="h-4 w-4"
    >
      {name === "revenue" && (
        <>
          <polyline points="3 16 9 10 13 14 21 6" />
          <polyline points="15 6 21 6 21 12" />
        </>
      )}
      {name === "signups" && (
        <>
          <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
          <circle cx="9" cy="7" r="4" />
          <path d="M19 8v6M22 11h-6" />
        </>
      )}
      {name === "conversion" && (
        <>
          <circle cx="12" cy="12" r="9" />
          <circle cx="12" cy="12" r="1.5" />
          <path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
        </>
      )}
      {name === "goal" && (
        <>
          <path d="M4 21V4" />
          <path d="M4 4h11l-1.6 4L15 12H4" />
        </>
      )}
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Delta chip — status colour NEVER carries meaning alone.             */
/* emerald/rose collide under deuteranopia, so the arrow + signed      */
/* number + screen-reader word are the real encoding.                  */
/* ------------------------------------------------------------------ */

function DeltaChip({ delta }: { delta: number }) {
  const up = delta >= 0;
  return (
    <span
      className={[
        "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
        up
          ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
          : "bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
      ].join(" ")}
    >
      <svg
        viewBox="0 0 12 12"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.6}
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        focusable="false"
        className="h-3 w-3"
      >
        {up ? (
          <path d="M6 10V2M2.6 5.4 6 2l3.4 3.4" />
        ) : (
          <path d="M6 2v8M2.6 6.6 6 10l3.4-3.4" />
        )}
      </svg>
      {signed(delta)}
      <span className="sr-only">{up ? "increase" : "decrease"}</span>
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Segmented control — roving tabindex radiogroup                      */
/* ------------------------------------------------------------------ */

interface Option<T extends string> {
  k: T;
  label: string;
  hint: string;
}

function Segmented<T extends string>({
  legend,
  value,
  options,
  onChange,
}: {
  legend: string;
  value: T;
  options: Option<T>[];
  onChange: (v: T) => void;
}) {
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const index = options.findIndex((o) => o.k === value);

  const focusTo = (next: number) => {
    const i = (next + options.length) % options.length;
    onChange(options[i].k);
    refs.current[i]?.focus();
  };

  return (
    <div
      role="radiogroup"
      aria-label={legend}
      className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-900"
    >
      {options.map((o, i) => {
        const active = o.k === value;
        return (
          <button
            key={o.k}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={active}
            aria-label={o.hint}
            tabIndex={active ? 0 : -1}
            onClick={() => onChange(o.k)}
            onKeyDown={(e) => {
              if (e.key === "ArrowRight" || e.key === "ArrowDown") {
                e.preventDefault();
                focusTo(index + 1);
              } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
                e.preventDefault();
                focusTo(index - 1);
              } else if (e.key === "Home") {
                e.preventDefault();
                focusTo(0);
              } else if (e.key === "End") {
                e.preventDefault();
                focusTo(options.length - 1);
              }
            }}
            className={[
              "rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
              "focus-visible: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-offset-slate-900",
              active
                ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
                : "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800",
            ].join(" ")}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Trend chart — measured in real pixels (no aspect distortion)        */
/* ------------------------------------------------------------------ */

function TrendChart({
  values,
  labels,
  unit,
  kind,
  metricLabel,
  rangeText,
  reduce,
}: {
  values: number[];
  labels: string[];
  unit: Unit;
  kind: TrendKind;
  metricLabel: string;
  rangeText: string;
  reduce: boolean;
}) {
  const rawId = useId();
  const gid = `csc-grad-${rawId.replace(/:/g, "")}`;
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const [w, setW] = useState(300);
  const [active, setActive] = useState<number | null>(null);

  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      const cr = entries[0]?.contentRect;
      if (cr && cr.width > 0) setW(Math.max(160, Math.round(cr.width)));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const geo = useMemo(() => {
    const n = values.length;
    const min = Math.min(...values);
    const max = Math.max(...values);
    const span = max - min || 1;
    const innerW = Math.max(1, w - PAD_X * 2);
    const xs = values.map((_, i) =>
      n === 1 ? w / 2 : PAD_X + (i / (n - 1)) * innerW,
    );
    const ys = values.map(
      (v) => PAD_T + (1 - (v - min) / span) * (BASELINE - PAD_T),
    );
    const pts: Pt[] = xs.map((x, i) => ({ x, y: ys[i] }));
    const line = buildSmoothPath(pts);
    const area =
      n > 1
        ? `${line} L ${xs[n - 1].toFixed(2)} ${H} L ${xs[0].toFixed(2)} ${H} Z`
        : "";
    const slot = innerW / n;
    const bw = Math.max(3, Math.min(14, slot - 4)); // >=2px surface gap, capped thin
    const bars = values.map((_, i) => ({
      x: xs[i] - bw / 2,
      y: ys[i],
      h: Math.max(3, BASELINE - ys[i]),
    }));
    return { n, xs, ys, line, area, bars, bw };
  }, [values, w]);

  const n = geo.n;
  const shown = active === null ? null : Math.min(active, n - 1);

  const step = (dir: number) =>
    setActive((i) => {
      const from = i === null ? n - 1 : Math.min(i, n - 1);
      return Math.min(n - 1, Math.max(0, from + dir));
    });

  const first = values[0];
  const last = values[n - 1];
  const delta = first === 0 ? 0 : ((last - first) / first) * 100;
  const kindWord = kind === "bars" ? "bar" : kind;

  return (
    <div className="mt-4">
      <div
        ref={wrapRef}
        tabIndex={0}
        role="group"
        aria-label={`${metricLabel} ${kindWord} chart. Latest ${formatValue(
          last,
          unit,
        )}, ${signed(delta)} across ${rangeText}. Use the left and right arrow keys to read each point.`}
        onPointerMove={(e) => {
          const rect = e.currentTarget.getBoundingClientRect();
          if (rect.width === 0) return;
          const px = ((e.clientX - rect.left) / rect.width) * w;
          let best = 0;
          let bestD = Infinity;
          for (let i = 0; i < geo.xs.length; i++) {
            const d = Math.abs(geo.xs[i] - px);
            if (d < bestD) {
              bestD = d;
              best = i;
            }
          }
          setActive(best);
        }}
        onPointerLeave={() => setActive(null)}
        onFocus={() => setActive((i) => (i === null ? n - 1 : i))}
        onBlur={() => setActive(null)}
        onKeyDown={(e) => {
          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(n - 1);
          } else if (e.key === "Escape") {
            setActive(null);
          }
        }}
        className="relative cursor-crosshair rounded-lg text-indigo-500 focus-visible: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-offset-slate-900"
        style={{ height: H }}
      >
        <svg
          width={w}
          height={H}
          viewBox={`0 0 ${w} ${H}`}
          className="block overflow-visible"
          aria-hidden="true"
          focusable="false"
        >
          <defs>
            <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="currentColor" stopOpacity={0.28} />
              <stop offset="100%" stopColor="currentColor" stopOpacity={0.02} />
            </linearGradient>
          </defs>

          {/* recessive solid hairline baseline — never dashed */}
          <line
            x1={0}
            y1={BASELINE + 0.5}
            x2={w}
            y2={BASELINE + 0.5}
            className="stroke-slate-200 dark:stroke-slate-800"
            strokeWidth={1}
          />

          {kind === "area" && (
            <>
              <motion.path
                d={geo.area}
                fill={`url(#${gid})`}
                {...(reduce
                  ? {}
                  : {
                      initial: { opacity: 0 },
                      animate: { opacity: 1 },
                      transition: { duration: 0.6, ease: "easeOut" },
                    })}
              />
              <motion.path
                d={geo.line}
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                {...(reduce
                  ? {}
                  : {
                      initial: { pathLength: 0 },
                      animate: { pathLength: 1 },
                      transition: { duration: 1, ease: "easeInOut" },
                    })}
              />
            </>
          )}

          {kind === "line" && (
            <motion.path
              d={geo.line}
              fill="none"
              stroke="currentColor"
              strokeWidth={2}
              strokeLinecap="round"
              strokeLinejoin="round"
              {...(reduce
                ? {}
                : {
                    initial: { pathLength: 0 },
                    animate: { pathLength: 1 },
                    transition: { duration: 1, ease: "easeInOut" },
                  })}
            />
          )}

          {kind === "bars" &&
            geo.bars.map((b, i) => (
              <motion.rect
                key={i}
                x={b.x}
                y={b.y}
                width={geo.bw}
                height={b.h}
                rx={4}
                className="fill-current"
                fillOpacity={shown === null || shown === i ? 1 : 0.45}
                {...(reduce
                  ? {}
                  : {
                      initial: { height: 0, y: BASELINE },
                      animate: { height: b.h, y: b.y },
                      transition: {
                        duration: 0.5,
                        delay: i * 0.03,
                        ease: "easeOut",
                      },
                    })}
              />
            ))}
        </svg>

        {/* Overlays live in HTML so markers stay circular and crisp */}
        {shown !== null && (
          <div className="pointer-events-none absolute inset-0">
            {kind !== "bars" && (
              <span
                className="absolute w-px bg-slate-300 dark:bg-slate-700"
                style={{
                  left: geo.xs[shown],
                  top: PAD_T,
                  height: BASELINE - PAD_T,
                }}
              />
            )}
            <span
              className="csc-halo absolute h-3 w-3 rounded-full bg-indigo-500"
              style={{
                left: geo.xs[shown],
                top: geo.ys[shown],
                transform: "translate(-50%, -50%)",
              }}
            />
            {/* 10px marker with a 2px surface ring */}
            <span
              className="absolute h-2.5 w-2.5 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-slate-900"
              style={{
                left: geo.xs[shown],
                top: geo.ys[shown],
                transform: "translate(-50%, -50%)",
              }}
            />
            <span
              className="csc-tip absolute z-10 whitespace-nowrap rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs shadow-sm dark:border-slate-700 dark:bg-slate-800"
              style={{
                left: Math.min(Math.max(geo.xs[shown], 46), Math.max(46, w - 46)),
                top: geo.ys[shown] - 12,
                transform: "translate(-50%, -100%)",
              }}
            >
              <span className="text-slate-500 dark:text-slate-400">
                {labels[shown]}
              </span>{" "}
              <span className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">
                {formatValue(values[shown], unit)}
              </span>
            </span>
          </div>
        )}
      </div>

      {/* x-axis band lives inside the card box, so nothing gets clipped */}
      <div className="mt-1 flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
        <span>{labels[0]}</span>
        <span>{labels[n - 1]}</span>
      </div>

      <span role="status" aria-live="polite" className="sr-only">
        {shown === null
          ? ""
          : `${labels[shown]}: ${formatValue(values[shown], unit)}`}
      </span>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Goal ring                                                           */
/* ------------------------------------------------------------------ */

function GoalRing({
  value,
  goal,
  reduce,
}: {
  value: number;
  goal: number;
  reduce: boolean;
}) {
  const pct = Math.round((value / goal) * 100);
  const r = 30;
  const circ = 2 * Math.PI * r;
  const offset = circ * (1 - Math.min(100, Math.max(0, pct)) / 100);

  return (
    <div className="mt-4 flex items-center gap-4">
      <div className="relative h-[76px] w-[76px] shrink-0">
        <svg
          viewBox="0 0 76 76"
          className="h-full w-full -rotate-90 text-indigo-500"
          aria-hidden="true"
          focusable="false"
        >
          <circle
            cx={38}
            cy={38}
            r={r}
            fill="none"
            strokeWidth={7}
            className="stroke-slate-200 dark:stroke-slate-800"
          />
          <motion.circle
            cx={38}
            cy={38}
            r={r}
            fill="none"
            stroke="currentColor"
            strokeWidth={7}
            strokeLinecap="round"
            strokeDasharray={circ}
            strokeDashoffset={offset}
            {...(reduce
              ? {}
              : {
                  initial: { strokeDashoffset: circ },
                  animate: { strokeDashoffset: offset },
                  transition: { duration: 1.1, ease: EASE_OUT },
                })}
          />
        </svg>
        <span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-slate-900 dark:text-slate-100">
          {pct}%
        </span>
      </div>
      <p className="text-xs leading-relaxed text-slate-500 dark:text-slate-400">
        <span className="font-medium text-slate-700 dark:text-slate-300">
          {pct}% of the {formatValue(goal, "currency")} target
        </span>
        <br />
        {formatValue(goal - value, "currency")} still to close by Sep 30.
      </p>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Card                                                                */
/* ------------------------------------------------------------------ */

function StatCard({
  metric,
  range,
  reduce,
  index,
}: {
  metric: Metric;
  range: RangeKey;
  reduce: boolean;
  index: number;
}) {
  const rise = reduce
    ? {}
    : {
        initial: { opacity: 0, y: 12 },
        animate: { opacity: 1, y: 0 },
        transition: { duration: 0.5, delay: index * 0.07, ease: EASE_OUT },
      };

  let valueText: string;
  let delta: number;
  if (metric.type === "trend") {
    const values = metric.series[range];
    const first = values[0];
    const last = values[values.length - 1];
    valueText = formatValue(last, metric.unit);
    delta = first === 0 ? 0 : ((last - first) / first) * 100;
  } else {
    valueText = formatValue(metric.value, "currency");
    delta = metric.deltaPct;
  }

  return (
    <motion.article
      {...rise}
      className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
    >
      <div className="flex items-center gap-2.5">
        <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
          <MetricIcon name={metric.icon} />
        </span>
        <h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
          {metric.label}
        </h3>
      </div>

      <div className="mt-3 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
        {/* proportional figures on the hero number — never tabular-nums */}
        <span className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
          {valueText}
        </span>
        <DeltaChip delta={delta} />
      </div>

      {metric.type === "trend" ? (
        <TrendChart
          values={metric.series[range]}
          labels={RANGE_LABELS[range]}
          unit={metric.unit}
          kind={metric.kind}
          metricLabel={metric.label}
          rangeText={RANGE_TEXT[range]}
          reduce={reduce}
        />
      ) : (
        <GoalRing value={metric.value} goal={metric.goal} reduce={reduce} />
      )}

      <p className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
        {metric.caption}
      </p>
    </motion.article>
  );
}

/* ------------------------------------------------------------------ */
/* Table view — the WCAG-clean twin of every chart above               */
/* ------------------------------------------------------------------ */

function DataTable({ range }: { range: RangeKey }) {
  const labels = RANGE_LABELS[range];
  const trends = METRICS.filter((m): m is TrendMetric => m.type === "trend");

  return (
    <div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
      <table className="w-full min-w-[520px] border-collapse text-sm">
        <caption className="px-5 pt-5 text-left text-xs text-slate-500 dark:text-slate-400">
          Every plotted value for {RANGE_TEXT[range]}, in full.
        </caption>
        <thead>
          <tr className="border-b border-slate-200 dark:border-slate-800">
            <th
              scope="col"
              className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
            >
              Period
            </th>
            {trends.map((m) => (
              <th
                key={m.id}
                scope="col"
                className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
              >
                {m.short}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {labels.map((label, i) => (
            <tr
              key={label}
              className="border-b border-slate-100 last:border-0 dark:border-slate-800/70"
            >
              <th
                scope="row"
                className="px-5 py-2.5 text-left font-medium text-slate-700 dark:text-slate-300"
              >
                {label}
              </th>
              {trends.map((m) => (
                <td
                  key={m.id}
                  className="px-5 py-2.5 text-right tabular-nums text-slate-600 dark:text-slate-400"
                >
                  {formatValue(m.series[range][i], m.unit)}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <p className="px-5 pb-5 pt-3 text-xs text-slate-500 dark:text-slate-400">
        Quarter target: $312K of $400K (78%), up 14.2% on Q2. Quarter to date, so it
        ignores the range filter.
      </p>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

const RANGE_OPTIONS: Option<RangeKey>[] = [
  { k: "7d", label: "7D", hint: "Last 7 days" },
  { k: "30d", label: "30D", hint: "Last 30 days" },
  { k: "90d", label: "90D", hint: "Last 90 days" },
];

const VIEW_OPTIONS: Option<ViewKey>[] = [
  { k: "chart", label: "Charts", hint: "Chart view" },
  { k: "table", label: "Table", hint: "Table view" },
];

const CSS = `
@keyframes csc-halo {
  0%, 100% { opacity: .45; transform: translate(-50%, -50%) scale(1); }
  50%      { opacity: 0;   transform: translate(-50%, -50%) scale(2.4); }
}
@keyframes csc-tip {
  from { opacity: 0; transform: translate(-50%, -100%) translateY(3px); }
  to   { opacity: 1; transform: translate(-50%, -100%) translateY(0); }
}
.csc-halo { animation: csc-halo 1.8s ease-in-out infinite; }
.csc-tip  { animation: csc-tip .14s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .csc-halo { animation: none; opacity: .35; }
  .csc-tip  { animation: none; }
}
`;

export default function ChartStatCombo() {
  const reduce = !!useReducedMotion();
  const [range, setRange] = useState<RangeKey>("30d");
  const [view, setView] = useState<ViewKey>("chart");

  return (
    <section className="relative w-full bg-slate-50 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{CSS}</style>

      <div className="mx-auto max-w-6xl px-6">
        <header className="flex flex-wrap items-end justify-between gap-6">
          <div className="max-w-lg">
            <p className="text-xs font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
              Growth
            </p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
              Performance overview
            </h2>
            <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Scrub any chart with your pointer, or focus it and use the{" "}
              <kbd className="rounded border border-slate-300 px-1 font-sans text-[11px] dark:border-slate-700">
                &larr;
              </kbd>{" "}
              <kbd className="rounded border border-slate-300 px-1 font-sans text-[11px] dark:border-slate-700">
                &rarr;
              </kbd>{" "}
              keys. Every value is also in the table view.
            </p>
          </div>

          {/* One filter row scoping everything below — never per-card filters */}
          <div className="flex flex-wrap items-center gap-2">
            <Segmented
              legend="Time range"
              value={range}
              options={RANGE_OPTIONS}
              onChange={setRange}
            />
            <Segmented
              legend="Display as"
              value={view}
              options={VIEW_OPTIONS}
              onChange={setView}
            />
          </div>
        </header>

        <div className="mt-10">
          {view === "chart" ? (
            <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4">
              {METRICS.map((m, i) => (
                <StatCard
                  key={m.id}
                  metric={m}
                  range={range}
                  reduce={reduce}
                  index={i}
                />
              ))}
            </div>
          ) : (
            <DataTable range={range} />
          )}
        </div>

        <p className="mt-6 text-xs text-slate-500 dark:text-slate-400">
          Figures are illustrative sample data. Colour never carries meaning on its
          own: each card is named and iconned, and every rise or fall ships with an
          arrow and a signed number.
        </p>
      </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 →