Web InnoventixFreeCode

Chart Area

Original · free

SVG area chart with gradient fill

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

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

type Metric = {
  id: string;
  name: string;
  unit: "" | "$";
  accent: string;
  dot: string;
  data: number[];
};

const MONTHS = [
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
];

const METRICS: Metric[] = [
  {
    id: "visitors",
    name: "Unique visitors",
    unit: "",
    accent: "text-indigo-600 dark:text-indigo-400",
    dot: "bg-indigo-500",
    data: [
      18420, 21030, 19850, 24310, 27690, 25120, 30240, 33110, 31580, 38260,
      41920, 46370,
    ],
  },
  {
    id: "signups",
    name: "New signups",
    unit: "",
    accent: "text-emerald-600 dark:text-emerald-400",
    dot: "bg-emerald-500",
    data: [412, 468, 455, 531, 604, 572, 690, 742, 715, 861, 940, 1058],
  },
  {
    id: "revenue",
    name: "MRR",
    unit: "$",
    accent: "text-amber-600 dark:text-amber-500",
    dot: "bg-amber-500",
    data: [
      8420, 9310, 8990, 11250, 13400, 12180, 15230, 16780, 16050, 19640, 21870,
      24510,
    ],
  },
];

const W = 768;
const H = 340;
const PAD_L = 54;
const PAD_R = 20;
const PAD_T = 20;
const PAD_B = 32;
const PLOT_X0 = PAD_L;
const PLOT_X1 = W - PAD_R;
const PLOT_W = PLOT_X1 - PLOT_X0;
const PLOT_H = H - PAD_T - PAD_B;
const BASELINE = PAD_T + PLOT_H;
const N = MONTHS.length;

const KEYFRAMES = `
@keyframes caz-pulse {
  0% { transform: scale(1); opacity: .5; }
  70% { transform: scale(2.6); opacity: 0; }
  100% { transform: scale(2.6); opacity: 0; }
}
@keyframes caz-tip-in { from { opacity: 0; } to { opacity: 1; } }
.caz-pulse-ring {
  animation: caz-pulse 2.4s ease-out infinite;
  transform-box: fill-box;
  transform-origin: center;
}
.caz-tip { animation: caz-tip-in .18s ease-out both; }
@media (prefers-reduced-motion: reduce) {
  .caz-pulse-ring, .caz-tip { animation: none !important; }
}
`;

function clamp(v: number, lo: number, hi: number): number {
  return Math.min(hi, Math.max(lo, v));
}

function fmt(metric: Metric, v: number): string {
  const s = Math.round(v).toLocaleString("en-US");
  return metric.unit === "$" ? `$${s}` : s;
}

function tickLabel(metric: Metric, v: number): string {
  const base = v >= 1000 ? `${v / 1000}k` : `${v}`;
  if (metric.unit === "$") return v === 0 ? "$0" : `$${base}`;
  return base;
}

export default function ChartArea() {
  const reduce = useReducedMotion();
  const uid = useId();
  const gradId = `${uid}-grad`;

  const [metricId, setMetricId] = useState<string>("visitors");
  const [active, setActive] = useState<number>(N - 1);
  const [show, setShow] = useState<boolean>(false);

  const metric = useMemo<Metric>(
    () => METRICS.find((m) => m.id === metricId) ?? METRICS[0],
    [metricId],
  );

  const yMax = useMemo<number>(() => {
    const max = Math.max(...metric.data);
    const mag = Math.pow(10, Math.floor(Math.log10(max)));
    return Math.ceil(max / mag) * mag;
  }, [metric]);

  const geo = useMemo(() => {
    const pts = metric.data.map((v, i) => ({
      x: PLOT_X0 + (i / (N - 1)) * PLOT_W,
      y: PAD_T + (1 - v / yMax) * PLOT_H,
      v,
    }));
    const line = pts
      .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`)
      .join(" ");
    const area =
      `M${pts[0].x.toFixed(2)},${BASELINE} ` +
      pts.map((p) => `L${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ") +
      ` L${pts[N - 1].x.toFixed(2)},${BASELINE} Z`;
    return { pts, line, area };
  }, [metric, yMax]);

  const stats = useMemo(() => {
    const d = metric.data;
    const total = d.reduce((a, b) => a + b, 0);
    const cur = d[N - 1];
    const prev = d[N - 2];
    return {
      latest: cur,
      peak: Math.max(...d),
      avg: total / N,
      total,
      deltaPct: ((cur - prev) / prev) * 100,
    };
  }, [metric]);

  const up = stats.deltaPct >= 0;
  const activePt = geo.pts[active];
  const tipLeft = clamp((activePt.x / W) * 100, 12, 88);
  const topPct = (activePt.y / H) * 100;

  function handlePointer(e: ReactPointerEvent<HTMLDivElement>): void {
    const rect = e.currentTarget.getBoundingClientRect();
    if (rect.width === 0) return;
    const px = ((e.clientX - rect.left) / rect.width) * W;
    const frac = (px - PLOT_X0) / PLOT_W;
    setActive(Math.round(clamp(frac, 0, 1) * (N - 1)));
  }

  function handleKey(e: ReactKeyboardEvent<HTMLDivElement>): void {
    let next = active;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = Math.min(N - 1, active + 1);
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = Math.max(0, active - 1);
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = N - 1;
        break;
      case "PageUp":
        next = Math.min(N - 1, active + 3);
        break;
      case "PageDown":
        next = Math.max(0, active - 3);
        break;
      default:
        return;
    }
    e.preventDefault();
    setActive(next);
  }

  const grid = [0, 0.25, 0.5, 0.75, 1];

  return (
    <section className="relative w-full bg-slate-50 py-16 dark:bg-slate-950 sm:py-20">
      <style>{KEYFRAMES}</style>

      <div className="mx-auto w-full max-w-3xl px-4 sm:px-6">
        <figure className="m-0 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm ring-1 ring-slate-900/[0.02] dark:border-slate-800 dark:bg-slate-900 dark:ring-white/[0.02]">
          {/* Header */}
          <div className="flex flex-col gap-5 border-b border-slate-100 px-6 pb-6 pt-6 dark:border-slate-800 sm:px-8 sm:pt-7">
            <div className="flex items-start justify-between gap-4">
              <div>
                <span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
                  <svg
                    viewBox="0 0 16 16"
                    className="h-3.5 w-3.5"
                    fill="none"
                    aria-hidden="true"
                  >
                    <path
                      d="M1.5 10.5 5 7l2.5 2L14 3"
                      stroke="currentColor"
                      strokeWidth="1.6"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                    <path
                      d="M1.5 10.5 5 7l2.5 2L14 3v10.5H1.5z"
                      fill="currentColor"
                      fillOpacity="0.12"
                    />
                  </svg>
                  Growth report
                </span>
                <h2 className="mt-1.5 text-lg font-semibold tracking-tight text-slate-900 dark:text-white sm:text-xl">
                  Product performance
                </h2>
                <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
                  Trailing 12 months &middot; through Jun 2026
                </p>
              </div>

              <span
                className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold ${
                  up
                    ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400"
                    : "bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400"
                }`}
              >
                <svg
                  viewBox="0 0 12 12"
                  className={`h-3 w-3 ${up ? "" : "rotate-180"}`}
                  fill="none"
                  aria-hidden="true"
                >
                  <path
                    d="M6 9.5V2.5M6 2.5 3 5.5M6 2.5l3 3"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
                {up ? "+" : ""}
                {stats.deltaPct.toFixed(1)}%
                <span className="sr-only"> versus previous month</span>
              </span>
            </div>

            <div className="flex items-end gap-3">
              <span
                className={`text-4xl font-semibold tracking-tight tabular-nums ${metric.accent}`}
              >
                {fmt(metric, stats.latest)}
              </span>
              <span className="pb-1 text-sm text-slate-500 dark:text-slate-400">
                {metric.name.toLowerCase()} in June
              </span>
            </div>

            {/* Metric selector */}
            <div
              role="group"
              aria-label="Select metric to chart"
              className="inline-flex flex-wrap gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/70"
            >
              {METRICS.map((m) => {
                const on = m.id === metricId;
                return (
                  <button
                    key={m.id}
                    type="button"
                    aria-pressed={on}
                    onClick={() => setMetricId(m.id)}
                    className={`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-800 ${
                      on
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    <span
                      className={`h-2 w-2 rounded-full ${m.dot} ${
                        on ? "" : "opacity-40"
                      }`}
                      aria-hidden="true"
                    />
                    {m.name}
                  </button>
                );
              })}
            </div>
          </div>

          {/* Chart */}
          <div className="px-3 pb-2 pt-5 sm:px-5">
            <div
              className="relative w-full"
              style={{ aspectRatio: `${W} / ${H}` }}
            >
              <svg
                viewBox={`0 0 ${W} ${H}`}
                className={`absolute inset-0 h-full w-full transition-colors duration-500 ${metric.accent}`}
                role="img"
                aria-label={`Area chart of ${metric.name.toLowerCase()} over the last 12 months.`}
                preserveAspectRatio="none"
              >
                <defs>
                  <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0%" stopColor="currentColor" stopOpacity="0.34" />
                    <stop offset="55%" stopColor="currentColor" stopOpacity="0.12" />
                    <stop offset="100%" stopColor="currentColor" stopOpacity="0.02" />
                  </linearGradient>
                </defs>

                {/* Gridlines + y labels */}
                {grid.map((f) => {
                  const y = PAD_T + (1 - f) * PLOT_H;
                  const labelled = f === 0 || f === 0.5 || f === 1;
                  return (
                    <g key={f}>
                      <line
                        x1={PLOT_X0}
                        y1={y}
                        x2={PLOT_X1}
                        y2={y}
                        className="stroke-slate-200 dark:stroke-slate-700/60"
                        strokeWidth={1}
                        strokeDasharray={f === 0 ? "0" : "3 5"}
                      />
                      {labelled ? (
                        <text
                          x={PLOT_X0 - 10}
                          y={y + 3.5}
                          textAnchor="end"
                          className="fill-slate-400 text-[10px] tabular-nums dark:fill-slate-500"
                        >
                          {tickLabel(metric, yMax * f)}
                        </text>
                      ) : null}
                    </g>
                  );
                })}

                {/* Area fill */}
                <motion.path
                  key={`fill-${metric.id}`}
                  d={geo.area}
                  fill={`url(#${gradId})`}
                  stroke="none"
                  initial={reduce ? false : { opacity: 0 }}
                  animate={reduce ? undefined : { opacity: 1 }}
                  transition={{ duration: 0.8, ease: "easeOut" }}
                />

                {/* Line */}
                <motion.path
                  key={`line-${metric.id}`}
                  d={geo.line}
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2.5}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={reduce ? false : { pathLength: 0 }}
                  animate={reduce ? undefined : { pathLength: 1 }}
                  transition={{ duration: 1.1, ease: "easeInOut" }}
                  vectorEffect="non-scaling-stroke"
                />

                {/* Crosshair + active marker */}
                <line
                  x1={activePt.x}
                  y1={PAD_T}
                  x2={activePt.x}
                  y2={BASELINE}
                  stroke="currentColor"
                  strokeWidth={1}
                  strokeDasharray="3 4"
                  strokeOpacity={0.5}
                />
                {!reduce ? (
                  <circle
                    className="caz-pulse-ring"
                    cx={activePt.x}
                    cy={activePt.y}
                    r={6}
                    fill="currentColor"
                  />
                ) : null}
                <circle
                  cx={activePt.x}
                  cy={activePt.y}
                  r={4.5}
                  className="fill-white dark:fill-slate-900"
                  stroke="currentColor"
                  strokeWidth={2.5}
                  vectorEffect="non-scaling-stroke"
                />

                {/* x labels */}
                {geo.pts.map((p, i) => (
                  <text
                    key={MONTHS[i]}
                    x={p.x}
                    y={H - 10}
                    textAnchor="middle"
                    className={`text-[10px] tabular-nums ${
                      i === active
                        ? "fill-slate-700 font-semibold dark:fill-slate-200"
                        : "fill-slate-400 dark:fill-slate-500"
                    }`}
                  >
                    {MONTHS[i]}
                  </text>
                ))}
              </svg>

              {/* Tooltip */}
              {show ? (
                <div
                  className="caz-tip pointer-events-none absolute z-10 whitespace-nowrap rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-slate-700 dark:bg-slate-800"
                  style={{
                    left: `${tipLeft}%`,
                    top: `${topPct}%`,
                    transform: "translate(-50%, calc(-100% - 12px))",
                  }}
                >
                  <div className="flex items-center gap-1.5 font-medium text-slate-500 dark:text-slate-400">
                    <span
                      className={`h-2 w-2 rounded-full ${metric.dot}`}
                      aria-hidden="true"
                    />
                    {MONTHS[active]} 2026
                  </div>
                  <div className="mt-0.5 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
                    {fmt(metric, activePt.v)}
                  </div>
                </div>
              ) : null}

              {/* Interactive scrubber */}
              <div
                role="slider"
                tabIndex={0}
                aria-label={`${metric.name} by month. Use arrow keys to inspect each of the last 12 months.`}
                aria-valuemin={0}
                aria-valuemax={N - 1}
                aria-valuenow={active}
                aria-valuetext={`${MONTHS[active]}: ${fmt(metric, activePt.v)}`}
                onKeyDown={handleKey}
                onPointerMove={handlePointer}
                onPointerDown={handlePointer}
                onPointerEnter={() => setShow(true)}
                onPointerLeave={() => setShow(false)}
                onFocus={() => setShow(true)}
                onBlur={() => setShow(false)}
                className="absolute inset-0 cursor-crosshair rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
              />
            </div>
          </div>

          {/* Stats footer */}
          <div className="grid grid-cols-2 gap-px overflow-hidden border-t border-slate-100 bg-slate-100 dark:border-slate-800 dark:bg-slate-800 sm:grid-cols-4">
            {[
              { label: "Latest", value: fmt(metric, stats.latest) },
              { label: "Peak month", value: fmt(metric, stats.peak) },
              { label: "Avg / mo", value: fmt(metric, stats.avg) },
              { label: "12-mo total", value: fmt(metric, stats.total) },
            ].map((s) => (
              <div
                key={s.label}
                className="bg-white px-5 py-4 dark:bg-slate-900"
              >
                <dt className="text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
                  {s.label}
                </dt>
                <dd className="mt-1 text-base font-semibold tabular-nums text-slate-900 dark:text-white">
                  {s.value}
                </dd>
              </div>
            ))}
          </div>

          <figcaption className="sr-only">
            {metric.name} for the trailing 12 months ending June 2026.
            <table>
              <thead>
                <tr>
                  <th scope="col">Month</th>
                  <th scope="col">{metric.name}</th>
                </tr>
              </thead>
              <tbody>
                {metric.data.map((v, i) => (
                  <tr key={MONTHS[i]}>
                    <th scope="row">{MONTHS[i]} 2026</th>
                    <td>{fmt(metric, v)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </figcaption>
        </figure>
      </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 →