Web InnoventixFreeCode

Dashboard Chart Widget

Original · free

chart panel widget with tabs

byWeb InnoventixReact + Tailwind
dashchartwidgetdashboards
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-chart-widget.json
dash-chart-widget.tsx
"use client";

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

type SeriesKey = "revenue" | "signups" | "churn";
type RangeKey = 7 | 14 | 30;

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

interface SeriesDef {
  key: SeriesKey;
  label: string;
  caption: string;
  agg: "sum" | "avg";
  goodWhen: "up" | "down";
  accent: string;
  swatch: string;
  data: readonly number[];
  format: (v: number) => string;
  formatAxis: (v: number) => string;
}

const DAY_MS = 86_400_000;
const END_UTC = Date.UTC(2026, 6, 17);
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

const LABELS: readonly string[] = Array.from({ length: 60 }, (_, i) => {
  const d = new Date(END_UTC - (59 - i) * DAY_MS);
  return `${MONTHS[d.getUTCMonth()] ?? ""} ${d.getUTCDate()}`;
});

const REVENUE: readonly number[] = [
  2410, 2580, 2496, 2634, 1810, 1682, 2520, 2688, 2742, 2610, 2830, 1904, 1755, 2612, 2764, 2910,
  2848, 3012, 2044, 1868, 2735, 2896, 3080, 2965, 3140, 2118, 1946, 2810, 3024, 3196, 3105, 3288,
  2210, 2032, 2944, 3162, 3340, 3255, 3402, 2286, 2115, 3068, 3288, 3475, 3390, 3560, 2380, 2204,
  3190, 3420, 3610, 3548, 3702, 2465, 2288, 3315, 3560, 3744, 3672, 3905,
];

const SIGNUPS: readonly number[] = [
  48, 52, 50, 55, 31, 28, 47, 54, 58, 55, 61, 34, 30, 52, 57, 63, 60, 66, 37, 33, 56, 62, 68, 65,
  71, 40, 36, 60, 66, 73, 70, 77, 43, 38, 64, 71, 78, 75, 82, 46, 41, 69, 75, 84, 80, 88, 49, 44,
  73, 80, 89, 86, 94, 53, 47, 78, 85, 95, 91, 101,
];

const CHURN: readonly number[] = [
  4.8, 4.7, 4.9, 4.6, 4.5, 4.7, 4.6, 4.5, 4.4, 4.6, 4.3, 4.4, 4.2, 4.3, 4.2, 4.1, 4.3, 4.0, 4.1,
  3.9, 4.0, 3.9, 4.0, 3.8, 3.7, 3.9, 3.6, 3.8, 3.7, 3.5, 3.6, 3.4, 3.6, 3.3, 3.5, 3.4, 3.2, 3.3,
  3.1, 3.3, 3.0, 3.2, 3.1, 3.0, 3.2, 2.9, 3.1, 2.8, 3.0, 2.9, 3.1, 2.8, 3.0, 2.7, 2.9, 2.8, 2.7,
  2.9, 2.6, 2.8,
];

function group(n: number): string {
  return Math.round(n)
    .toString()
    .replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

const SERIES: readonly SeriesDef[] = [
  {
    key: "revenue",
    label: "Revenue",
    caption: "Net MRR booked per day, refunds deducted.",
    agg: "sum",
    goodWhen: "up",
    accent: "text-indigo-600 dark:text-indigo-400",
    swatch: "bg-indigo-600 dark:bg-indigo-400",
    data: REVENUE,
    format: (v) => `$${group(v)}`,
    formatAxis: (v) => (v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${group(v)}`),
  },
  {
    key: "signups",
    label: "Signups",
    caption: "Verified accounts created, bots filtered out.",
    agg: "sum",
    goodWhen: "up",
    accent: "text-emerald-600 dark:text-emerald-400",
    swatch: "bg-emerald-600 dark:bg-emerald-400",
    data: SIGNUPS,
    format: (v) => group(v),
    formatAxis: (v) => group(v),
  },
  {
    key: "churn",
    label: "Churn rate",
    caption: "Rolling 30-day logo churn, billed workspaces only.",
    agg: "avg",
    goodWhen: "down",
    accent: "text-rose-600 dark:text-rose-400",
    swatch: "bg-rose-600 dark:bg-rose-400",
    data: CHURN,
    format: (v) => `${v.toFixed(1)}%`,
    formatAxis: (v) => `${v.toFixed(1)}%`,
  },
];

const RANGES: readonly { key: RangeKey; label: string; long: string }[] = [
  { key: 7, label: "7D", long: "Last 7 days" },
  { key: 14, label: "14D", long: "Last 14 days" },
  { key: 30, label: "30D", long: "Last 30 days" },
];

const W = 720;
const H = 260;
const PAD_L = 52;
const PAD_R = 16;
const PAD_T = 20;
const PAD_B = 34;
const PLOT_W = W - PAD_L - PAD_R;
const PLOT_H = H - PAD_T - PAD_B;

function toCurve(pts: readonly Pt[]): string {
  const n = pts.length;
  if (n === 0) return "";
  const get = (i: number): Pt => pts[Math.min(n - 1, Math.max(0, i))] ?? { x: 0, y: 0 };
  let d = `M ${get(0).x.toFixed(2)} ${get(0).y.toFixed(2)}`;
  for (let i = 0; i < n - 1; i += 1) {
    const p0 = get(i - 1);
    const p1 = get(i);
    const p2 = get(i + 1);
    const p3 = get(i + 2);
    const c1x = p1.x + (p2.x - p0.x) / 6;
    const c1y = p1.y + (p2.y - p0.y) / 6;
    const c2x = p2.x - (p3.x - p1.x) / 6;
    const c2y = p2.y - (p3.y - p1.y) / 6;
    d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
  }
  return d;
}

function toPolyline(pts: readonly Pt[]): string {
  return pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(" ");
}

function aggregate(values: readonly number[], agg: "sum" | "avg"): number {
  if (values.length === 0) return 0;
  const total = values.reduce((acc, v) => acc + v, 0);
  return agg === "sum" ? total : total / values.length;
}

export default function DashChartWidget() {
  const reduce = useReducedMotion();
  const rawId = useId();
  const uid = rawId.replace(/:/g, "");

  const [seriesKey, setSeriesKey] = useState<SeriesKey>("revenue");
  const [range, setRange] = useState<RangeKey>(30);
  const [compare, setCompare] = useState<boolean>(true);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [chartFocused, setChartFocused] = useState<boolean>(false);

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

  const seriesIndex = SERIES.findIndex((s) => s.key === seriesKey);
  const series = SERIES[seriesIndex] ?? SERIES[0];
  const rangeIndex = RANGES.findIndex((r) => r.key === range);

  const model = useMemo(() => {
    const def = SERIES.find((s) => s.key === seriesKey) ?? SERIES[0];
    const data = def?.data ?? [];
    const n = range;
    const start = data.length - n;
    const current = data.slice(start);
    const previous = data.slice(start - n, start);

    const pool = compare ? [...current, ...previous] : [...current];
    const rawMin = Math.min(...pool);
    const rawMax = Math.max(...pool);
    const spread = rawMax - rawMin || Math.abs(rawMax) || 1;
    const minV = rawMin - spread * 0.14;
    const maxV = rawMax + spread * 0.14;

    const xOf = (i: number): number => PAD_L + (n === 1 ? PLOT_W / 2 : (i / (n - 1)) * PLOT_W);
    const yOf = (v: number): number => PAD_T + (1 - (v - minV) / (maxV - minV)) * PLOT_H;

    const curPts: Pt[] = current.map((v, i) => ({ x: xOf(i), y: yOf(v) }));
    const prevPts: Pt[] = previous.map((v, i) => ({ x: xOf(i), y: yOf(v) }));

    const baseY = PAD_T + PLOT_H;
    const firstX = curPts[0]?.x ?? PAD_L;
    const lastX = curPts[curPts.length - 1]?.x ?? PAD_L + PLOT_W;
    const linePath = toCurve(curPts);
    const areaPath = `${linePath} L ${lastX.toFixed(2)} ${baseY} L ${firstX.toFixed(2)} ${baseY} Z`;

    const ticks = Array.from({ length: 5 }, (_, t) => {
      const v = maxV - ((maxV - minV) * t) / 4;
      return { y: yOf(v), label: def?.formatAxis(v) ?? "" };
    });

    const labelStep = Math.max(1, Math.floor((n - 1) / 4));
    const xTicks = current
      .map((_, i) => i)
      .filter((i) => i % labelStep === 0 || i === n - 1)
      .map((i) => ({ i, x: xOf(i), label: LABELS[start + i] ?? "" }));

    const curAgg = aggregate(current, def?.agg ?? "sum");
    const prevAgg = aggregate(previous, def?.agg ?? "sum");
    const deltaPct = prevAgg === 0 ? 0 : ((curAgg - prevAgg) / prevAgg) * 100;

    let peakI = 0;
    let lowI = 0;
    current.forEach((v, i) => {
      if (v > (current[peakI] ?? 0)) peakI = i;
      if (v < (current[lowI] ?? 0)) lowI = i;
    });

    return {
      n,
      start,
      current,
      previous,
      curPts,
      prevPts,
      linePath,
      areaPath,
      prevPath: toPolyline(prevPts),
      ticks,
      xTicks,
      curAgg,
      prevAgg,
      deltaPct,
      peakI,
      lowI,
      baseY,
      average: aggregate(current, "avg"),
    };
  }, [seriesKey, range, compare]);

  const displayIndex = activeIndex ?? model.n - 1;
  const activePt = model.curPts[displayIndex];
  const activeValue = model.current[displayIndex] ?? 0;
  const comparePt = model.prevPts[displayIndex];
  const compareValue = model.previous[displayIndex] ?? 0;
  const activeLabel = LABELS[model.start + displayIndex] ?? "";
  const prevLabel = LABELS[model.start - model.n + displayIndex] ?? "";
  const showTip = activeIndex !== null;

  const positive = model.deltaPct >= 0;
  const good = series
    ? (series.goodWhen === "up" && positive) || (series.goodWhen === "down" && !positive)
    : positive;

  const onTabKeyDown = useCallback((e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
    if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(e.key)) return;
    e.preventDefault();
    let next = index;
    if (e.key === "ArrowRight") next = (index + 1) % SERIES.length;
    if (e.key === "ArrowLeft") next = (index - 1 + SERIES.length) % SERIES.length;
    if (e.key === "Home") next = 0;
    if (e.key === "End") next = SERIES.length - 1;
    const def = SERIES[next];
    if (!def) return;
    setSeriesKey(def.key);
    setActiveIndex(null);
    tabRefs.current[next]?.focus();
  }, []);

  const onRangeKeyDown = useCallback((e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
    if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(e.key)) return;
    e.preventDefault();
    let next = index;
    if (e.key === "ArrowRight") next = (index + 1) % RANGES.length;
    if (e.key === "ArrowLeft") next = (index - 1 + RANGES.length) % RANGES.length;
    if (e.key === "Home") next = 0;
    if (e.key === "End") next = RANGES.length - 1;
    const def = RANGES[next];
    if (!def) return;
    setRange(def.key);
    setActiveIndex(null);
    rangeRefs.current[next]?.focus();
  }, []);

  const onChartKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      const last = model.n - 1;
      const cur = activeIndex ?? last;
      if (e.key === "ArrowRight") {
        e.preventDefault();
        setActiveIndex(Math.min(last, cur + 1));
      } else if (e.key === "ArrowLeft") {
        e.preventDefault();
        setActiveIndex(Math.max(0, cur - 1));
      } else if (e.key === "Home") {
        e.preventDefault();
        setActiveIndex(0);
      } else if (e.key === "End") {
        e.preventDefault();
        setActiveIndex(last);
      } else if (e.key === "Escape") {
        setActiveIndex(null);
      }
    },
    [activeIndex, model.n],
  );

  const tipLeft = Math.min(88, Math.max(12, ((activePt?.x ?? PAD_L) / W) * 100));
  const tipTop = ((activePt?.y ?? PAD_T) / H) * 100;
  const rangeLong = RANGES[rangeIndex]?.long ?? "Last 30 days";
  const panelId = `${uid}-panel`;

  return (
    <section className="relative w-full bg-zinc-100 px-4 py-16 sm:px-6 sm:py-20 dark:bg-zinc-950">
      <style>{`
        @keyframes dcw-rise {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes dcw-pulse-ring {
          0% { transform: scale(1); opacity: 0.55; }
          70% { transform: scale(2.4); opacity: 0; }
          100% { transform: scale(2.4); opacity: 0; }
        }
        .dcw-rise { animation: dcw-rise 620ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        .dcw-ring { animation: dcw-pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .dcw-rise, .dcw-ring { animation: none !important; }
        }
      `}</style>

      <div className="dcw-rise mx-auto w-full max-w-5xl">
        <div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/30">
          {/* Header */}
          <div className="flex flex-col gap-4 border-b border-zinc-200 p-5 sm:p-6 dark:border-zinc-800">
            <div className="flex flex-wrap items-start justify-between gap-4">
              <div>
                <div className="flex items-center gap-2">
                  <h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
                    Growth overview
                  </h2>
                  <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 ring-1 ring-emerald-600/20 ring-inset dark:bg-emerald-950/60 dark:text-emerald-300 dark:ring-emerald-400/20">
                    <span className="relative flex h-1.5 w-1.5">
                      <span className="dcw-ring absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                      <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
                    </span>
                    Live
                  </span>
                </div>
                <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
                  Northwind Studio workspace · synced 4 min ago · UTC
                </p>
              </div>

              {/* Range radiogroup */}
              <div
                role="radiogroup"
                aria-label="Time range"
                className="inline-flex rounded-lg bg-zinc-100 p-0.5 dark:bg-zinc-800"
              >
                {RANGES.map((r, i) => {
                  const selected = r.key === range;
                  return (
                    <button
                      key={r.key}
                      ref={(el) => {
                        rangeRefs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={selected}
                      aria-label={r.long}
                      tabIndex={selected ? 0 : -1}
                      onClick={() => {
                        setRange(r.key);
                        setActiveIndex(null);
                      }}
                      onKeyDown={(e) => onRangeKeyDown(e, i)}
                      className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-800 ${
                        selected
                          ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-50"
                          : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100"
                      }`}
                    >
                      {r.label}
                    </button>
                  );
                })}
              </div>
            </div>

            {/* Tabs */}
            <div
              role="tablist"
              aria-label="Metric"
              aria-orientation="horizontal"
              className="-mb-5 flex gap-1 sm:-mb-6"
            >
              {SERIES.map((s, i) => {
                const selected = s.key === seriesKey;
                return (
                  <button
                    key={s.key}
                    ref={(el) => {
                      tabRefs.current[i] = el;
                    }}
                    type="button"
                    role="tab"
                    id={`${uid}-tab-${s.key}`}
                    aria-selected={selected}
                    aria-controls={panelId}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => {
                      setSeriesKey(s.key);
                      setActiveIndex(null);
                    }}
                    onKeyDown={(e) => onTabKeyDown(e, i)}
                    className={`relative rounded-t-md px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-inset ${
                      selected
                        ? "text-zinc-900 dark:text-zinc-50"
                        : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100"
                    }`}
                  >
                    {s.label}
                    {selected ? (
                      <motion.span
                        layoutId={`${uid}-tab-underline`}
                        className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-zinc-900 dark:bg-zinc-50"
                        transition={
                          reduce
                            ? { duration: 0 }
                            : { type: "spring", stiffness: 420, damping: 34 }
                        }
                      />
                    ) : null}
                  </button>
                );
              })}
            </div>
          </div>

          {/* Panel */}
          <div
            role="tabpanel"
            id={panelId}
            aria-labelledby={`${uid}-tab-${series?.key ?? "revenue"}`}
            className="p-5 sm:p-6"
          >
            <div className="flex flex-wrap items-end justify-between gap-4">
              <div>
                <div className="flex items-baseline gap-3">
                  <span className="text-3xl font-semibold tracking-tight tabular-nums text-zinc-900 sm:text-4xl dark:text-zinc-50">
                    {series?.format(model.curAgg)}
                  </span>
                  <span
                    className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ring-1 ring-inset ${
                      good
                        ? "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950/60 dark:text-emerald-300 dark:ring-emerald-400/20"
                        : "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-950/60 dark:text-rose-300 dark:ring-rose-400/20"
                    }`}
                  >
                    <svg
                      viewBox="0 0 16 16"
                      aria-hidden="true"
                      className={`h-3 w-3 ${positive ? "" : "rotate-180"}`}
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    >
                      <path d="M8 13V3" />
                      <path d="M3.5 7.5 8 3l4.5 4.5" />
                    </svg>
                    {positive ? "+" : "−"}
                    {Math.abs(model.deltaPct).toFixed(1)}%
                  </span>
                </div>
                <p className="mt-1.5 text-sm text-zinc-500 dark:text-zinc-400">
                  {rangeLong} vs previous {model.n} days · {series?.caption}
                </p>
              </div>

              {/* Compare switch */}
              <div className="flex items-center gap-2.5">
                <span
                  id={`${uid}-cmp-label`}
                  className="text-xs font-medium text-zinc-600 dark:text-zinc-400"
                >
                  Compare previous period
                </span>
                <button
                  type="button"
                  role="switch"
                  aria-checked={compare}
                  aria-labelledby={`${uid}-cmp-label`}
                  onClick={() => setCompare((v) => !v)}
                  className={`relative inline-flex h-5 w-9 shrink-0 rounded-full 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-zinc-900 ${
                    compare ? "bg-indigo-600 dark:bg-indigo-500" : "bg-zinc-300 dark:bg-zinc-700"
                  }`}
                >
                  <span
                    className={`pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ease-out ${
                      compare ? "translate-x-4" : "translate-x-0"
                    }`}
                  />
                </button>
              </div>
            </div>

            {/* Chart */}
            <div
              tabIndex={0}
              role="group"
              aria-label={`${series?.label ?? "Metric"} chart, ${rangeLong}. Use the left and right arrow keys to inspect each day.`}
              onKeyDown={onChartKeyDown}
              onFocus={() => {
                setChartFocused(true);
                setActiveIndex((v) => v ?? model.n - 1);
              }}
              onBlur={() => {
                setChartFocused(false);
                setActiveIndex(null);
              }}
              onPointerLeave={() => {
                if (!chartFocused) setActiveIndex(null);
              }}
              className="relative mt-5 rounded-xl 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-zinc-900"
            >
              <svg
                viewBox={`0 0 ${W} ${H}`}
                className={`h-auto w-full ${series?.accent ?? "text-indigo-600"}`}
                role="img"
                aria-label={`${series?.label ?? "Metric"} from ${LABELS[model.start] ?? ""} to ${LABELS[59] ?? ""}. Values are listed in the data table below.`}
              >
                <defs>
                  <linearGradient id={`${uid}-fill`} x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
                    <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
                  </linearGradient>
                </defs>

                {/* Gridlines + y labels */}
                {model.ticks.map((t, i) => (
                  <g key={`y-${i}`}>
                    <line
                      x1={PAD_L}
                      x2={W - PAD_R}
                      y1={t.y}
                      y2={t.y}
                      className="stroke-zinc-200 dark:stroke-zinc-800"
                      strokeWidth="1"
                      strokeDasharray={i === model.ticks.length - 1 ? undefined : "3 5"}
                    />
                    <text
                      x={PAD_L - 10}
                      y={t.y + 3.5}
                      textAnchor="end"
                      className="fill-zinc-400 text-[10px] tabular-nums dark:fill-zinc-500"
                    >
                      {t.label}
                    </text>
                  </g>
                ))}

                {/* X labels */}
                {model.xTicks.map((t) => (
                  <text
                    key={`x-${t.i}`}
                    x={t.x}
                    y={H - 12}
                    textAnchor="middle"
                    className="fill-zinc-400 text-[10px] dark:fill-zinc-500"
                  >
                    {t.label}
                  </text>
                ))}

                {/* Previous period */}
                {compare ? (
                  <motion.path
                    key={`${seriesKey}-${range}-prev`}
                    d={model.prevPath}
                    fill="none"
                    strokeDasharray="4 4"
                    strokeWidth="1.75"
                    strokeLinecap="round"
                    className="stroke-zinc-400 dark:stroke-zinc-600"
                    initial={reduce ? false : { opacity: 0 }}
                    animate={{ opacity: 1 }}
                    transition={{ duration: reduce ? 0 : 0.4 }}
                  />
                ) : null}

                {/* Area */}
                <motion.path
                  key={`${seriesKey}-${range}-${compare ? "c" : "n"}-area`}
                  d={model.areaPath}
                  fill={`url(#${uid}-fill)`}
                  initial={reduce ? false : { opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: reduce ? 0 : 0.55, delay: reduce ? 0 : 0.18 }}
                />

                {/* Line */}
                <motion.path
                  key={`${seriesKey}-${range}-${compare ? "c" : "n"}-line`}
                  d={model.linePath}
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2.5"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={reduce ? false : { pathLength: 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{ duration: reduce ? 0 : 0.85, ease: [0.22, 1, 0.36, 1] }}
                />

                {/* Point markers on short ranges */}
                {model.n <= 14
                  ? model.curPts.map((p, i) => (
                      <circle
                        key={`dot-${i}`}
                        cx={p.x}
                        cy={p.y}
                        r="3"
                        fill="currentColor"
                        className="opacity-70"
                      />
                    ))
                  : null}

                {/* Crosshair + active point */}
                {showTip && activePt ? (
                  <g>
                    <line
                      x1={activePt.x}
                      x2={activePt.x}
                      y1={PAD_T}
                      y2={model.baseY}
                      className="stroke-zinc-300 dark:stroke-zinc-600"
                      strokeWidth="1"
                    />
                    {compare && comparePt ? (
                      <circle
                        cx={comparePt.x}
                        cy={comparePt.y}
                        r="3.5"
                        className="fill-white stroke-zinc-400 dark:fill-zinc-900 dark:stroke-zinc-500"
                        strokeWidth="2"
                      />
                    ) : null}
                    <circle cx={activePt.x} cy={activePt.y} r="7" fill="currentColor" opacity="0.18" />
                    <circle
                      cx={activePt.x}
                      cy={activePt.y}
                      r="4"
                      fill="currentColor"
                      className="stroke-white dark:stroke-zinc-900"
                      strokeWidth="2"
                    />
                  </g>
                ) : null}

                {/* Hit areas */}
                {model.curPts.map((p, i) => (
                  <rect
                    key={`hit-${i}`}
                    x={p.x - PLOT_W / (model.n - 1) / 2}
                    y={PAD_T}
                    width={PLOT_W / (model.n - 1)}
                    height={PLOT_H}
                    fill="transparent"
                    onMouseEnter={() => setActiveIndex(i)}
                  />
                ))}
              </svg>

              {/* Tooltip */}
              {showTip && activePt ? (
                <div
                  className="pointer-events-none absolute z-10 w-max -translate-x-1/2 -translate-y-[calc(100%+14px)] rounded-lg border border-zinc-200 bg-white px-3 py-2 shadow-lg shadow-zinc-900/10 dark:border-zinc-700 dark:bg-zinc-950 dark:shadow-black/50"
                  style={{ left: `${tipLeft}%`, top: `${tipTop}%` }}
                >
                  <p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
                    {activeLabel}, 2026
                  </p>
                  <p className="mt-0.5 flex items-center gap-1.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
                    <span className={`h-2 w-2 rounded-full ${series?.swatch ?? "bg-indigo-600"}`} />
                    {series?.format(activeValue)}
                  </p>
                  {compare ? (
                    <p className="mt-0.5 flex items-center gap-1.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
                      <span className="h-2 w-2 rounded-full border border-dashed border-zinc-400 dark:border-zinc-500" />
                      {series?.format(compareValue)} on {prevLabel}
                    </p>
                  ) : null}
                </div>
              ) : null}
            </div>

            {/* Legend */}
            <div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-zinc-500 dark:text-zinc-400">
              <span className="inline-flex items-center gap-1.5">
                <span className={`h-0.5 w-4 rounded-full ${series?.swatch ?? "bg-indigo-600"}`} />
                {rangeLong}
              </span>
              {compare ? (
                <span className="inline-flex items-center gap-1.5">
                  <span className="h-0 w-4 border-t-2 border-dashed border-zinc-400 dark:border-zinc-500" />
                  Previous {model.n} days
                </span>
              ) : null}
              <span className="ml-auto hidden sm:inline">
                Focus the chart and use ← → to step through days
              </span>
            </div>

            {/* Footer stats */}
            <dl className="mt-5 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-zinc-200 bg-zinc-200 sm:grid-cols-3 dark:border-zinc-800 dark:bg-zinc-800">
              <div className="bg-white px-4 py-3 dark:bg-zinc-900">
                <dt className="text-xs text-zinc-500 dark:text-zinc-400">Best day</dt>
                <dd className="mt-0.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
                  {series?.format(model.current[model.peakI] ?? 0)}
                  <span className="ml-1.5 font-normal text-zinc-400 dark:text-zinc-500">
                    {LABELS[model.start + model.peakI] ?? ""}
                  </span>
                </dd>
              </div>
              <div className="bg-white px-4 py-3 dark:bg-zinc-900">
                <dt className="text-xs text-zinc-500 dark:text-zinc-400">Daily average</dt>
                <dd className="mt-0.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
                  {series?.format(model.average)}
                </dd>
              </div>
              <div className="bg-white px-4 py-3 dark:bg-zinc-900">
                <dt className="text-xs text-zinc-500 dark:text-zinc-400">Quietest day</dt>
                <dd className="mt-0.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
                  {series?.format(model.current[model.lowI] ?? 0)}
                  <span className="ml-1.5 font-normal text-zinc-400 dark:text-zinc-500">
                    {LABELS[model.start + model.lowI] ?? ""}
                  </span>
                </dd>
              </div>
            </dl>

            {/* Screen-reader announcements + data table */}
            <p aria-live="polite" className="sr-only">
              {showTip
                ? `${activeLabel}: ${series?.format(activeValue) ?? ""}${
                    compare ? `, previous period ${series?.format(compareValue) ?? ""}` : ""
                  }`
                : ""}
            </p>
            <table className="sr-only">
              <caption>
                {series?.label ?? "Metric"} by day, {rangeLong}
              </caption>
              <thead>
                <tr>
                  <th scope="col">Date</th>
                  <th scope="col">{series?.label ?? "Value"}</th>
                  <th scope="col">Previous period</th>
                </tr>
              </thead>
              <tbody>
                {model.current.map((v, i) => (
                  <tr key={`row-${i}`}>
                    <th scope="row">{LABELS[model.start + i] ?? ""}</th>
                    <td>{series?.format(v)}</td>
                    <td>{series?.format(model.previous[i] ?? 0)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </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 →