Web InnoventixFreeCode

Dashboard Revenue

Original · free

revenue overview card with chart

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

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

type RangeId = "7d" | "30d" | "12m";

type Point = {
  /** Short axis label. */
  label: string;
  /** Full label used in the tooltip and the screen-reader table. */
  full: string;
  value: number;
  prev: number;
};

type RangeDef = {
  id: RangeId;
  label: string;
  /** How the comparison period is described in prose. */
  comparison: string;
  points: Point[];
};

type Channel = {
  name: string;
  share: number;
  delta: number;
  bar: string;
  dot: string;
};

const RANGES: RangeDef[] = [
  {
    id: "7d",
    label: "7 days",
    comparison: "the 7 days before",
    points: [
      { label: "Sat", full: "Saturday 11 July", value: 8420, prev: 7180 },
      { label: "Sun", full: "Sunday 12 July", value: 7160, prev: 6240 },
      { label: "Mon", full: "Monday 13 July", value: 13240, prev: 10980 },
      { label: "Tue", full: "Tuesday 14 July", value: 14880, prev: 11640 },
      { label: "Wed", full: "Wednesday 15 July", value: 12960, prev: 11020 },
      { label: "Thu", full: "Thursday 16 July", value: 15310, prev: 12470 },
      { label: "Fri", full: "Friday 17 July", value: 11840, prev: 9860 },
    ],
  },
  {
    id: "30d",
    label: "30 days",
    comparison: "the 30 days before",
    points: [
      { label: "18 Jun", full: "Thursday 18 June", value: 12480, prev: 10240 },
      { label: "19 Jun", full: "Friday 19 June", value: 11920, prev: 9880 },
      { label: "20 Jun", full: "Saturday 20 June", value: 8140, prev: 6920 },
      { label: "21 Jun", full: "Sunday 21 June", value: 7480, prev: 6310 },
      { label: "22 Jun", full: "Monday 22 June", value: 13060, prev: 10720 },
      { label: "23 Jun", full: "Tuesday 23 June", value: 14210, prev: 11380 },
      { label: "24 Jun", full: "Wednesday 24 June", value: 13740, prev: 11150 },
      { label: "25 Jun", full: "Thursday 25 June", value: 12890, prev: 10640 },
      { label: "26 Jun", full: "Friday 26 June", value: 11460, prev: 9720 },
      { label: "27 Jun", full: "Saturday 27 June", value: 7920, prev: 6840 },
      { label: "28 Jun", full: "Sunday 28 June", value: 7310, prev: 6180 },
      { label: "29 Jun", full: "Monday 29 June", value: 13580, prev: 11090 },
      { label: "30 Jun", full: "Tuesday 30 June", value: 15640, prev: 12360 },
      { label: "1 Jul", full: "Wednesday 1 July", value: 16820, prev: 13240 },
      { label: "2 Jul", full: "Thursday 2 July", value: 14360, prev: 11720 },
      { label: "3 Jul", full: "Friday 3 July", value: 12740, prev: 10380 },
      { label: "4 Jul", full: "Saturday 4 July", value: 8260, prev: 7040 },
      { label: "5 Jul", full: "Sunday 5 July", value: 7690, prev: 6420 },
      { label: "6 Jul", full: "Monday 6 July", value: 13920, prev: 11240 },
      { label: "7 Jul", full: "Tuesday 7 July", value: 14680, prev: 11860 },
      { label: "8 Jul", full: "Wednesday 8 July", value: 14120, prev: 11430 },
      { label: "9 Jul", full: "Thursday 9 July", value: 13380, prev: 10920 },
      { label: "10 Jul", full: "Friday 10 July", value: 12240, prev: 10060 },
      { label: "11 Jul", full: "Saturday 11 July", value: 8420, prev: 7180 },
      { label: "12 Jul", full: "Sunday 12 July", value: 7160, prev: 6240 },
      { label: "13 Jul", full: "Monday 13 July", value: 13240, prev: 10980 },
      { label: "14 Jul", full: "Tuesday 14 July", value: 14880, prev: 11640 },
      { label: "15 Jul", full: "Wednesday 15 July", value: 12960, prev: 11020 },
      { label: "16 Jul", full: "Thursday 16 July", value: 15310, prev: 12470 },
      { label: "17 Jul", full: "Friday 17 July", value: 11840, prev: 9860 },
    ],
  },
  {
    id: "12m",
    label: "12 months",
    comparison: "the same months last year",
    points: [
      { label: "Aug", full: "August 2025", value: 186400, prev: 142300 },
      { label: "Sep", full: "September 2025", value: 194200, prev: 151800 },
      { label: "Oct", full: "October 2025", value: 178900, prev: 139400 },
      { label: "Nov", full: "November 2025", value: 212600, prev: 168200 },
      { label: "Dec", full: "December 2025", value: 268300, prev: 205600 },
      { label: "Jan", full: "January 2026", value: 241800, prev: 187300 },
      { label: "Feb", full: "February 2026", value: 229400, prev: 176900 },
      { label: "Mar", full: "March 2026", value: 254700, prev: 194500 },
      { label: "Apr", full: "April 2026", value: 271200, prev: 208700 },
      { label: "May", full: "May 2026", value: 288500, prev: 221400 },
      { label: "Jun", full: "June 2026", value: 305900, prev: 233800 },
      { label: "Jul", full: "July 2026", value: 342600, prev: 249100 },
    ],
  },
];

const CHANNELS: Channel[] = [
  {
    name: "Subscriptions",
    share: 0.58,
    delta: 14.2,
    bar: "bg-indigo-500",
    dot: "bg-indigo-500",
  },
  {
    name: "One-time licences",
    share: 0.21,
    delta: 6.8,
    bar: "bg-violet-500",
    dot: "bg-violet-500",
  },
  {
    name: "Professional services",
    share: 0.13,
    delta: 21.5,
    bar: "bg-sky-500",
    dot: "bg-sky-500",
  },
  {
    name: "Marketplace apps",
    share: 0.08,
    delta: -3.4,
    bar: "bg-amber-500",
    dot: "bg-amber-500",
  },
];

const money = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

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

const VB_W = 760;
const VB_H = 280;
const PAD_TOP = 18;
const PAD_RIGHT = 18;
const PAD_BOTTOM = 34;
const PAD_LEFT = 58;
const PLOT_W = VB_W - PAD_LEFT - PAD_RIGHT;
const PLOT_H = VB_H - PAD_TOP - PAD_BOTTOM;
const BASE_Y = PAD_TOP + PLOT_H;
const NICE_STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];

function niceCeil(value: number): number {
  if (value <= 0) return 1;
  const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
  const scaled = value / magnitude;
  const step = NICE_STEPS.find((candidate) => scaled <= candidate) ?? 10;
  return step * magnitude;
}

function buildLine(coords: Array<{ x: number; y: number }>): string {
  if (coords.length === 0) return "";
  let d = `M ${coords[0].x} ${coords[0].y}`;
  for (let i = 1; i < coords.length; i += 1) {
    const from = coords[i - 1];
    const to = coords[i];
    const midX = (from.x + to.x) / 2;
    d += ` C ${midX} ${from.y} ${midX} ${to.y} ${to.x} ${to.y}`;
  }
  return d;
}

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

const KEYFRAMES = `
@keyframes dashrev-ping {
  0% { transform: scale(1); opacity: 0.65; }
  70% { transform: scale(2.6); opacity: 0; }
  100% { transform: scale(2.6); opacity: 0; }
}
.dashrev-ping { animation: dashrev-ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite; }
@media (prefers-reduced-motion: reduce) {
  .dashrev-ping { animation: none !important; }
}
`;

export default function DashRevenue() {
  const rawId = useId();
  const uid = rawId.replace(/[^a-zA-Z0-9]/g, "");
  const reduceMotion = useReducedMotion();

  const [rangeId, setRangeId] = useState<RangeId>("30d");
  const [compare, setCompare] = useState(true);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

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

  const range = useMemo(
    () => RANGES.find((entry) => entry.id === rangeId) ?? RANGES[1],
    [rangeId],
  );

  const chart = useMemo(() => {
    const points = range.points;
    const count = points.length;
    const peak = Math.max(
      ...points.map((point) => Math.max(point.value, point.prev)),
    );
    const max = niceCeil(peak);
    const step = count > 1 ? PLOT_W / (count - 1) : 0;
    const toX = (index: number) => PAD_LEFT + index * step;
    const toY = (value: number) => PAD_TOP + (1 - value / max) * PLOT_H;

    const current = points.map((point, index) => ({
      x: toX(index),
      y: toY(point.value),
    }));
    const previous = points.map((point, index) => ({
      x: toX(index),
      y: toY(point.prev),
    }));

    const line = buildLine(current);
    const area =
      current.length > 0
        ? `${line} L ${current[current.length - 1].x} ${BASE_Y} L ${current[0].x} ${BASE_Y} Z`
        : "";

    const total = points.reduce((sum, point) => sum + point.value, 0);
    const prevTotal = points.reduce((sum, point) => sum + point.prev, 0);
    const delta = prevTotal === 0 ? 0 : ((total - prevTotal) / prevTotal) * 100;

    const best = points.reduce(
      (top, point) => (point.value > top.value ? point : top),
      points[0],
    );

    const ticks = Array.from({ length: 5 }, (_, i) => {
      const value = (max / 4) * i;
      return { value, y: toY(value) };
    });

    const labelEvery = count > 12 ? 5 : 1;

    return {
      points,
      count,
      max,
      step,
      current,
      previous,
      line,
      area,
      prevLine: buildLine(previous),
      total,
      prevTotal,
      delta,
      best,
      ticks,
      labelEvery,
    };
  }, [range]);

  const active = activeIndex === null ? null : chart.points[activeIndex];
  const activeCoord = activeIndex === null ? null : chart.current[activeIndex];

  const pickNearest = useCallback(
    (clientX: number, rect: DOMRect) => {
      if (rect.width === 0) return;
      const ratio = (clientX - rect.left) / rect.width;
      const vx = ratio * VB_W;
      const raw = chart.step === 0 ? 0 : (vx - PAD_LEFT) / chart.step;
      const index = Math.min(chart.count - 1, Math.max(0, Math.round(raw)));
      setActiveIndex(index);
    },
    [chart.count, chart.step],
  );

  const handlePointerMove = useCallback(
    (event: ReactPointerEvent<SVGSVGElement>) => {
      pickNearest(event.clientX, event.currentTarget.getBoundingClientRect());
    },
    [pickNearest],
  );

  const handleChartKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>) => {
      const { key } = event;
      if (
        key !== "ArrowLeft" &&
        key !== "ArrowRight" &&
        key !== "Home" &&
        key !== "End" &&
        key !== "Escape"
      ) {
        return;
      }
      event.preventDefault();
      if (key === "Escape") {
        setActiveIndex(null);
        return;
      }
      if (key === "Home") {
        setActiveIndex(0);
        return;
      }
      if (key === "End") {
        setActiveIndex(chart.count - 1);
        return;
      }
      const direction = key === "ArrowRight" ? 1 : -1;
      setActiveIndex((prev) => {
        if (prev === null) return direction === 1 ? 0 : chart.count - 1;
        return Math.min(chart.count - 1, Math.max(0, prev + direction));
      });
    },
    [chart.count],
  );

  const handleTabKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
      const { key } = event;
      if (
        key !== "ArrowLeft" &&
        key !== "ArrowRight" &&
        key !== "Home" &&
        key !== "End"
      )
        return;
      event.preventDefault();
      let next = index;
      if (key === "ArrowRight") next = (index + 1) % RANGES.length;
      if (key === "ArrowLeft")
        next = (index - 1 + RANGES.length) % RANGES.length;
      if (key === "Home") next = 0;
      if (key === "End") next = RANGES.length - 1;
      const target = RANGES[next];
      setRangeId(target.id);
      setActiveIndex(null);
      tabRefs.current[next]?.focus();
    },
    [],
  );

  const selectRange = useCallback((id: RangeId) => {
    setRangeId(id);
    setActiveIndex(null);
  }, []);

  const up = chart.delta >= 0;
  const tooltipLeft = activeCoord
    ? Math.min(93, Math.max(7, (activeCoord.x / VB_W) * 100))
    : 0;

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

      <div className="mx-auto w-full max-w-5xl">
        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          {/* Header */}
          <div className="flex flex-col gap-6 border-b border-slate-200 p-6 sm:p-8 dark:border-slate-800">
            <div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
              <div>
                <div className="flex items-center gap-2">
                  <span className="relative flex h-2 w-2">
                    <span className="dashrev-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
                    <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
                  </span>
                  <span className="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400">
                    Live · synced 2 minutes ago
                  </span>
                </div>

                <h2 className="mt-3 text-lg font-semibold text-slate-900 dark:text-slate-50">
                  Revenue overview
                </h2>
                <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                  Net of refunds and processing fees · USD
                </p>

                <div className="mt-5 flex flex-wrap items-center gap-x-3 gap-y-2">
                  <span className="text-3xl font-semibold tracking-tight tabular-nums text-slate-900 sm:text-4xl dark:text-slate-50">
                    {money.format(chart.total)}
                  </span>
                  <span
                    className={[
                      "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
                      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",
                    ].join(" ")}
                  >
                    <svg
                      viewBox="0 0 16 16"
                      className={["h-3 w-3", up ? "" : "rotate-180"].join(" ")}
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M8 13V3" />
                      <path d="M3.5 7.5 8 3l4.5 4.5" />
                    </svg>
                    {formatDelta(chart.delta)}
                  </span>
                </div>

                <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
                  vs{" "}
                  <span className="font-medium tabular-nums text-slate-700 dark:text-slate-300">
                    {money.format(chart.prevTotal)}
                  </span>{" "}
                  in {range.comparison}
                </p>
              </div>

              {/* Range tabs */}
              <div
                role="tablist"
                aria-label="Time range"
                className="inline-flex shrink-0 rounded-lg border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800"
              >
                {RANGES.map((entry, index) => {
                  const selected = entry.id === rangeId;
                  return (
                    <button
                      key={entry.id}
                      ref={(node) => {
                        tabRefs.current[index] = node;
                      }}
                      type="button"
                      role="tab"
                      id={`${uid}-tab-${entry.id}`}
                      aria-selected={selected}
                      aria-controls={`${uid}-panel`}
                      tabIndex={selected ? 0 : -1}
                      onClick={() => selectRange(entry.id)}
                      onKeyDown={(event) => handleTabKeyDown(event, index)}
                      className={[
                        "rounded-md px-3 py-1.5 text-sm font-medium transition-colors outline-none",
                        "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800",
                        selected
                          ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-slate-50"
                          : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100",
                      ].join(" ")}
                    >
                      {entry.label}
                    </button>
                  );
                })}
              </div>
            </div>

            {/* Legend + compare switch */}
            <div className="flex flex-wrap items-center justify-between gap-4">
              <div className="flex flex-wrap items-center gap-4 text-xs text-slate-600 dark:text-slate-400">
                <span className="inline-flex items-center gap-2">
                  <span className="h-2 w-2 rounded-full bg-indigo-500" />
                  This period
                </span>
                {compare ? (
                  <span className="inline-flex items-center gap-2">
                    <svg
                      viewBox="0 0 16 4"
                      className="h-1 w-4"
                      aria-hidden="true"
                    >
                      <line
                        x1="0"
                        y1="2"
                        x2="16"
                        y2="2"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeDasharray="4 3"
                        className="text-slate-400 dark:text-slate-500"
                      />
                    </svg>
                    Previous period
                  </span>
                ) : null}
              </div>

              <button
                type="button"
                role="switch"
                aria-checked={compare}
                onClick={() => setCompare((prev) => !prev)}
                className="group inline-flex items-center gap-2.5 rounded-md px-1 py-1 text-sm font-medium text-slate-700 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-900"
              >
                <span
                  aria-hidden="true"
                  className={[
                    "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
                    compare
                      ? "bg-indigo-600"
                      : "bg-slate-300 dark:bg-slate-700",
                  ].join(" ")}
                >
                  <span
                    className={[
                      "inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform",
                      compare ? "translate-x-4" : "translate-x-0.5",
                    ].join(" ")}
                  />
                </span>
                Compare to previous
              </button>
            </div>
          </div>

          {/* Chart */}
          <div
            id={`${uid}-panel`}
            role="tabpanel"
            aria-labelledby={`${uid}-tab-${rangeId}`}
            tabIndex={0}
            onKeyDown={handleChartKeyDown}
            onFocus={() =>
              setActiveIndex((prev) => (prev === null ? chart.count - 1 : prev))
            }
            onBlur={() => setActiveIndex(null)}
            aria-describedby={`${uid}-hint`}
            className="relative px-2 pt-6 pb-2 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-inset sm:px-4"
          >
            <p id={`${uid}-hint`} className="sr-only">
              Revenue for the last {range.label}. Use the left and right arrow
              keys to move between data points, Home and End to jump to the
              first or last, and Escape to clear.
            </p>

            <div className="relative">
              <svg
                viewBox={`0 0 ${VB_W} ${VB_H}`}
                className="block w-full touch-none"
                role="presentation"
                onPointerMove={handlePointerMove}
                onPointerLeave={() => setActiveIndex(null)}
              >
                <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.02"
                    />
                  </linearGradient>
                </defs>

                {/* Gridlines + y axis */}
                {chart.ticks.map((tick) => (
                  <g key={tick.value}>
                    <line
                      x1={PAD_LEFT}
                      y1={tick.y}
                      x2={VB_W - PAD_RIGHT}
                      y2={tick.y}
                      className="stroke-slate-200 dark:stroke-slate-800"
                      strokeWidth="1"
                    />
                    <text
                      x={PAD_LEFT - 12}
                      y={tick.y}
                      textAnchor="end"
                      dominantBaseline="middle"
                      className="fill-slate-400 text-[11px] tabular-nums dark:fill-slate-500"
                    >
                      {tick.value === 0
                        ? "$0"
                        : moneyCompact.format(tick.value)}
                    </text>
                  </g>
                ))}

                {/* X axis labels */}
                {chart.points.map((point, index) => {
                  const show =
                    index % chart.labelEvery === 0 || index === chart.count - 1;
                  if (!show) return null;
                  return (
                    <text
                      key={point.full}
                      x={chart.current[index].x}
                      y={BASE_Y + 20}
                      textAnchor="middle"
                      className="fill-slate-400 text-[11px] dark:fill-slate-500"
                    >
                      {point.label}
                    </text>
                  );
                })}

                {/* Previous period */}
                {compare ? (
                  <motion.path
                    key={`${rangeId}-prev`}
                    d={chart.prevLine}
                    fill="none"
                    strokeWidth="2"
                    strokeDasharray="5 4"
                    strokeLinecap="round"
                    className="stroke-slate-400 dark:stroke-slate-600"
                    initial={
                      reduceMotion ? false : { pathLength: 0, opacity: 0 }
                    }
                    animate={{ pathLength: 1, opacity: 1 }}
                    transition={{ duration: 0.7, ease: "easeOut" }}
                  />
                ) : null}

                {/* Area */}
                <motion.path
                  key={`${rangeId}-area`}
                  d={chart.area}
                  fill={`url(#${uid}-fill)`}
                  className="text-indigo-500"
                  initial={reduceMotion ? false : { opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: 0.5, ease: "easeOut", delay: 0.1 }}
                />

                {/* Line */}
                <motion.path
                  key={`${rangeId}-line`}
                  d={chart.line}
                  fill="none"
                  strokeWidth="2.5"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="stroke-indigo-600 dark:stroke-indigo-400"
                  initial={reduceMotion ? false : { pathLength: 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{ duration: 0.9, ease: "easeInOut" }}
                />

                {/* Active marker */}
                {activeCoord ? (
                  <g>
                    <line
                      x1={activeCoord.x}
                      y1={PAD_TOP}
                      x2={activeCoord.x}
                      y2={BASE_Y}
                      className="stroke-slate-300 dark:stroke-slate-600"
                      strokeWidth="1"
                      strokeDasharray="3 3"
                    />
                    {compare && activeIndex !== null ? (
                      <circle
                        cx={chart.previous[activeIndex].x}
                        cy={chart.previous[activeIndex].y}
                        r="3.5"
                        className="fill-white stroke-slate-400 dark:fill-slate-900 dark:stroke-slate-500"
                        strokeWidth="2"
                      />
                    ) : null}
                    <circle
                      cx={activeCoord.x}
                      cy={activeCoord.y}
                      r="5"
                      className="fill-white stroke-indigo-600 dark:fill-slate-900 dark:stroke-indigo-400"
                      strokeWidth="2.5"
                    />
                  </g>
                ) : null}
              </svg>

              {/* Tooltip — anchored to the same box as the svg so it tracks the marker */}
              {active && activeCoord ? (
                <div
                  aria-hidden="true"
                  className="pointer-events-none absolute z-10 -translate-x-1/2"
                  style={{
                    left: `${tooltipLeft}%`,
                    top: `${(PAD_TOP / VB_H) * 100}%`,
                  }}
                >
                  <div className="min-w-[10rem] rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur dark:border-slate-700 dark:bg-slate-800/95">
                    <p className="text-[11px] font-medium text-slate-500 dark:text-slate-400">
                      {active.full}
                    </p>
                    <p className="mt-1 text-base font-semibold tabular-nums text-slate-900 dark:text-slate-50">
                      {money.format(active.value)}
                    </p>
                    {compare ? (
                      <p className="mt-1 text-xs tabular-nums text-slate-500 dark:text-slate-400">
                        Previous {money.format(active.prev)} ·{" "}
                        <span
                          className={
                            active.value >= active.prev
                              ? "font-medium text-emerald-600 dark:text-emerald-400"
                              : "font-medium text-rose-600 dark:text-rose-400"
                          }
                        >
                          {formatDelta(
                            ((active.value - active.prev) / active.prev) * 100,
                          )}
                        </span>
                      </p>
                    ) : null}
                  </div>
                </div>
              ) : null}
            </div>

            {/* Announcements + full data for assistive tech */}
            <p aria-live="polite" className="sr-only">
              {active ? `${active.full}: ${money.format(active.value)}` : ""}
            </p>

            <table className="sr-only">
              <caption>{`Revenue for the last ${range.label}, compared with ${range.comparison}`}</caption>
              <thead>
                <tr>
                  <th scope="col">Period</th>
                  <th scope="col">Revenue</th>
                  <th scope="col">Previous</th>
                </tr>
              </thead>
              <tbody>
                {chart.points.map((point) => (
                  <tr key={point.full}>
                    <th scope="row">{point.full}</th>
                    <td>{money.format(point.value)}</td>
                    <td>{money.format(point.prev)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>

          {/* Channel breakdown */}
          <div className="border-t border-slate-200 p-6 sm:p-8 dark:border-slate-800">
            <div className="flex items-baseline justify-between gap-4">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50">
                Where it came from
              </h3>
              <p className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                Best {range.id === "12m" ? "month" : "day"}: {chart.best.full} ·{" "}
                {money.format(chart.best.value)}
              </p>
            </div>

            <ul className="mt-5 grid gap-x-8 gap-y-5 sm:grid-cols-2">
              {CHANNELS.map((channel, index) => {
                const amount = chart.total * channel.share;
                const positive = channel.delta >= 0;
                return (
                  <li key={channel.name}>
                    <div className="flex items-center justify-between gap-3 text-sm">
                      <span className="inline-flex items-center gap-2 text-slate-600 dark:text-slate-400">
                        <span
                          className={`h-2 w-2 rounded-full ${channel.dot}`}
                        />
                        {channel.name}
                      </span>
                      <span className="font-medium tabular-nums text-slate-900 dark:text-slate-100">
                        {money.format(amount)}
                      </span>
                    </div>
                    <div className="mt-2 flex items-center gap-3">
                      <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
                        <motion.div
                          className={`h-full rounded-full ${channel.bar}`}
                          initial={reduceMotion ? false : { width: 0 }}
                          animate={{
                            width: `${(channel.share / CHANNELS[0].share) * 100}%`,
                          }}
                          transition={{
                            duration: 0.7,
                            ease: "easeOut",
                            delay: reduceMotion ? 0 : 0.15 + index * 0.08,
                          }}
                        />
                      </div>
                      <span
                        className={[
                          "w-14 shrink-0 text-right text-xs font-medium tabular-nums",
                          positive
                            ? "text-emerald-600 dark:text-emerald-400"
                            : "text-rose-600 dark:text-rose-400",
                        ].join(" ")}
                      >
                        {formatDelta(channel.delta)}
                      </span>
                    </div>
                  </li>
                );
              })}
            </ul>
          </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 →