Revenue Dashboard with Self Drawing Line Chart and Scrubbing Crosshair

A dark revenue card built around one SVG line chart with no chart library. On load the line and its soft fill wipe in from the left. A crosshair then sweeps along the curve by itself, with a glowing dot and a tooltip that trails behind on a spring and flips sides near the edge. The big number, the change badge and three small tiles all read from the point under the crosshair, so scrubbing the chart updates the whole card. Three range buttons morph the curve into a new shape. On phones the axis labels drop away and the chart keeps its full width.

Code

import { useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { motion, useInView, useReducedMotion, useSpring } from "framer-motion";

/** Every range has the same number of points so the line can morph from one to the next. */
const N = 28;
const TIP_W = 172;

/** Small repeatable random walk, so the demo data is the same on every load. */
function walk(seed: number, start: number, end: number, noise: number, wave: number): number[] {
  let s = seed;
  const rnd = () => ((s = (s * 9301 + 49297) % 233280) / 233280) - 0.5;
  return Array.from({ length: N }, (_, i) => {
    const t = i / (N - 1);
    return Math.round(start + (end - start) * t + Math.sin(i * wave) * noise * 0.9 + rnd() * noise);
  });
}

const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const months = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct"];

const ranges = [
  {
    key: "1W",
    name: "This week",
    ticks: weekdays,
    current: walk(7, 2900, 4700, 620, 1.55),
    previous: walk(19, 2700, 3600, 380, 1.55),
    label: (i: number) => `${weekdays[Math.floor(i / 4)]} ${["00:00", "06:00", "12:00", "18:00"][i % 4]}`,
  },
  {
    key: "4W",
    name: "Last 4 weeks",
    ticks: ["Sep 8", "Sep 15", "Sep 22", "Sep 29", "Oct 5"],
    current: walk(41, 18200, 31800, 3100, 0.9),
    previous: walk(5, 17400, 24100, 2100, 0.9),
    label: (i: number) => (i < 23 ? `Sep ${i + 8}` : `Oct ${i - 22}`),
  },
  {
    key: "6M",
    name: "Last 6 months",
    ticks: months,
    current: walk(88, 96000, 214000, 15000, 0.5),
    previous: walk(64, 90000, 151000, 9000, 0.5),
    label: (i: number) => `${months[Math.min(6, Math.floor(i / 4.1))]}, week ${(i % 4) + 1}`,
  },
];

const money = (v: number) => "$" + Math.round(v).toLocaleString("en-US");
const short = (v: number) => (v >= 1000 ? "$" + (v / 1000).toFixed(v >= 10000 ? 0 : 1) + "k" : "$" + v);

/** A smooth curve through the points, in a 1000 x 300 box. */
function curve(values: number[], lo: number, hi: number, close: boolean): string {
  const pts = values.map((v, i) => [(i / (N - 1)) * 1000, 280 - ((v - lo) / (hi - lo)) * 250]);
  const f = (n: number) => n.toFixed(1);
  let d = `M${f(pts[0][0])},${f(pts[0][1])}`;
  for (let i = 0; i < N - 1; i++) {
    const p0 = pts[i - 1] ?? pts[i];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[i + 2] ?? p2;
    d += `C${f(p1[0] + (p2[0] - p0[0]) / 6)},${f(p1[1] + (p2[1] - p0[1]) / 6)} ${f(p2[0] - (p3[0] - p1[0]) / 6)},${f(p2[1] - (p3[1] - p1[1]) / 6)} ${f(p2[0])},${f(p2[1])}`;
  }
  return close ? d + "L1000,300L0,300Z" : d;
}

export default function RevenueDashboardWithSelfDrawingLineChartAndScrubbingCrosshair() {
  const reduce = useReducedMotion();
  const [rangeIndex, setRangeIndex] = useState(1);
  const [index, setIndex] = useState(N - 1);
  const [auto, setAuto] = useState(true);
  const [size, setSize] = useState({ w: 720, h: 280 });
  const plot = useRef<HTMLDivElement>(null);
  const resume = useRef<number | undefined>(undefined);
  const visible = useInView(plot, { amount: 0.3 });

  const range = ranges[rangeIndex];
  const { lo, hi, line, area, ghost } = useMemo(() => {
    const all = [...range.current, ...range.previous];
    const min = Math.min(...all);
    const max = Math.max(...all);
    const pad = (max - min) * 0.08;
    return {
      lo: min - pad,
      hi: max + pad,
      line: curve(range.current, min - pad, max + pad, false),
      area: curve(range.current, min - pad, max + pad, true),
      ghost: curve(range.previous, min - pad, max + pad, false),
    };
  }, [range]);

  const value = range.current[index];
  const before = range.previous[index];
  const change = ((value - before) / before) * 100;
  const px = (index / (N - 1)) * size.w;
  const py = ((280 - ((value - lo) / (hi - lo)) * 250) / 300) * size.h;
  const flip = px + 18 + TIP_W > size.w;

  const spring = { stiffness: 320, damping: 32, mass: 0.6 };
  const x = useSpring(px, spring);
  const y = useSpring(py, spring);
  const tipX = useSpring(18, { stiffness: 260, damping: 28 });
  const tipY = useSpring(py, { stiffness: 180, damping: 26 });

  useEffect(() => {
    const ty = Math.min(Math.max(py, 52), size.h - 52);
    const tx = flip ? -(TIP_W + 18) : 18;
    if (reduce) {
      x.jump(px);
      y.jump(py);
      tipX.jump(tx);
      tipY.jump(ty);
    } else {
      x.set(px);
      y.set(py);
      tipX.set(tx);
      tipY.set(ty);
    }
  }, [px, py, flip, size.h, reduce, x, y, tipX, tipY]);

  // Keep the pixel size of the plot, so the crosshair can be moved with transforms only.
  useEffect(() => {
    const el = plot.current;
    if (!el) return;
    const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight });
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Self demo: the crosshair sweeps back and forth until someone takes over.
  useEffect(() => {
    if (!auto || reduce || !visible) return;
    let t = 0;
    let timer: number | undefined;
    const start = window.setTimeout(() => {
      timer = window.setInterval(() => {
        t += 90;
        setIndex(Math.round((N - 1) * (0.5 + 0.5 * Math.cos(t / 1050))));
      }, 90);
    }, 1500);
    return () => {
      window.clearTimeout(start);
      window.clearInterval(timer);
    };
  }, [auto, reduce, visible]);

  useEffect(() => () => window.clearTimeout(resume.current), []);

  const takeOver = () => {
    window.clearTimeout(resume.current);
    setAuto(false);
  };
  const handBack = (delay: number) => {
    window.clearTimeout(resume.current);
    resume.current = window.setTimeout(() => setAuto(true), delay);
  };
  const scrub = (e: PointerEvent<HTMLDivElement>) => {
    const box = e.currentTarget.getBoundingClientRect();
    const t = Math.min(1, Math.max(0, (e.clientX - box.left) / box.width));
    takeOver();
    setIndex(Math.round(t * (N - 1)));
  };
  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const next = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: N - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    takeOver();
    setIndex(Math.min(N - 1, Math.max(0, next)));
  };

  const orders = Math.round(value / 58);
  const tiles = [
    { name: "Orders", value: orders.toLocaleString("en-US") },
    { name: "Average order", value: "$" + (value / orders).toFixed(2) },
    { name: "Checkout rate", value: (2.4 + ((value - lo) / (hi - lo)) * 1.9).toFixed(1) + "%" },
  ];
  const draw = reduce ? { duration: 0 } : { duration: 1.15, ease: [0.65, 0, 0.2, 1] as const, delay: 0.15 };
  const morph = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 120, damping: 20 };

  return (
    <section className="bg-slate-950 px-4 py-14 sm:px-6 sm:py-16">
      <div className="mx-auto max-w-4xl rounded-3xl border border-white/10 bg-gradient-to-b from-slate-900 to-slate-950 p-5 shadow-2xl shadow-black/50 sm:p-8">
        <div className="flex flex-wrap items-start justify-between gap-4">
          <div>
            <p className="flex items-center gap-2 text-sm font-medium text-slate-400">
              <span className="relative flex h-2 w-2">
                <span className="absolute inset-0 animate-ping rounded-full bg-lime-300 opacity-60 motion-reduce:animate-none" />
                <span className="relative h-2 w-2 rounded-full bg-lime-300" />
              </span>
              Net revenue ยท {range.label(index)}
            </p>
            <h2 className="mt-2 text-4xl font-semibold tracking-tight text-white tabular-nums sm:text-5xl">{money(value)}</h2>
            <p className="mt-2 flex items-center gap-2 text-sm text-slate-400">
              <span className={`rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ${change >= 0 ? "bg-lime-300/15 text-lime-300" : "bg-rose-400/15 text-rose-300"}`}>
                {change >= 0 ? "+" : ""}
                {change.toFixed(1)}%
              </span>
              against the period before
            </p>
          </div>
          <div role="group" aria-label="Date range" className="flex rounded-full border border-white/10 bg-white/5 p-1">
            {ranges.map((r, i) => (
              <button
                key={r.key}
                type="button"
                aria-pressed={i === rangeIndex}
                aria-label={r.name}
                onClick={() => setRangeIndex(i)}
                className="relative h-10 min-w-[52px] rounded-full px-3 text-sm font-semibold text-slate-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300"
              >
                {i === rangeIndex && <motion.span layoutId="revenue-dashboard-range-pill" transition={morph} className="absolute inset-0 rounded-full bg-lime-300" />}
                <span className={`relative ${i === rangeIndex ? "text-slate-950" : ""}`}>{r.key}</span>
              </button>
            ))}
          </div>
        </div>

        <div className="mt-8 flex gap-3">
          <div aria-hidden="true" className="hidden h-56 flex-col justify-between py-3 text-right text-xs tabular-nums text-slate-500 sm:flex sm:h-72">
            {[1, 0.66, 0.33, 0].map((t) => (
              <span key={t}>{short(Math.round((lo + (hi - lo) * t) / 100) * 100)}</span>
            ))}
          </div>
          <div className="min-w-0 flex-1">
            <div
              ref={plot}
              role="slider"
              tabIndex={0}
              aria-label={`Revenue chart, ${range.name}. Use the arrow keys to move along it.`}
              aria-valuemin={0}
              aria-valuemax={N - 1}
              aria-valuenow={index}
              aria-valuetext={`${range.label(index)}: ${money(value)}, ${change.toFixed(1)} percent against the period before`}
              onKeyDown={onKey}
              onFocus={takeOver}
              onBlur={() => handBack(1200)}
              onPointerDown={scrub}
              onPointerMove={scrub}
              onPointerLeave={() => handBack(900)}
              onPointerUp={(e) => e.pointerType !== "mouse" && handBack(2400)}
              className="relative h-56 cursor-crosshair touch-pan-y select-none rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 sm:h-72"
            >
              <div aria-hidden="true" className="absolute inset-0 flex flex-col justify-between py-3">
                {[0, 1, 2, 3].map((g) => (
                  <span key={g} className="h-px w-full bg-white/[0.07]" />
                ))}
              </div>
              <motion.div
                aria-hidden="true"
                className="absolute inset-0"
                initial={{ clipPath: reduce ? "inset(0% 0% 0% 0%)" : "inset(0% 100% 0% 0%)" }}
                animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
                transition={draw}
              >
                <svg viewBox="0 0 1000 300" preserveAspectRatio="none" className="absolute inset-0 h-full w-full overflow-visible">
                  <defs>
                    <linearGradient id="revenue-dashboard-fill" x1="0" y1="0" x2="0" y2="1">
                      <stop offset="0" stopColor="#bef264" stopOpacity="0.28" />
                      <stop offset="1" stopColor="#bef264" stopOpacity="0" />
                    </linearGradient>
                  </defs>
                  <motion.path initial={false} animate={{ d: ghost }} transition={morph} fill="none" stroke="#64748b" strokeWidth="1.5" strokeDasharray="5 6" vectorEffect="non-scaling-stroke" />
                  <motion.path initial={false} animate={{ d: area }} transition={morph} fill="url(#revenue-dashboard-fill)" />
                  <motion.path initial={false} animate={{ d: line }} transition={morph} fill="none" stroke="#bef264" strokeWidth="9" strokeOpacity="0.22" strokeLinecap="round" vectorEffect="non-scaling-stroke" className="blur-sm" />
                  <motion.path initial={false} animate={{ d: line }} transition={morph} fill="none" stroke="#bef264" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
                </svg>
              </motion.div>

              <motion.div
                aria-hidden="true"
                className="pointer-events-none absolute inset-0"
                initial={{ opacity: reduce ? 1 : 0 }}
                animate={{ opacity: 1 }}
                transition={reduce ? { duration: 0 } : { delay: 1.25, duration: 0.3 }}
              >
                <motion.span style={{ x }} className="absolute left-0 top-0 h-full w-px bg-gradient-to-b from-lime-300/0 via-lime-300/70 to-lime-300/0" />
                <motion.span style={{ x, y }} className="absolute left-0 top-0">
                  <span className="absolute -left-4 -top-4 h-8 w-8 animate-ping rounded-full bg-lime-300/30 motion-reduce:animate-none" />
                  <span className="absolute -left-[7px] -top-[7px] h-3.5 w-3.5 rounded-full border-[3px] border-slate-950 bg-lime-300 shadow-[0_0_0_2px_rgba(190,242,100,0.5)]" />
                </motion.span>
                <motion.div style={{ x, y: tipY }} className="absolute left-0 top-0 h-0 w-0">
                  <motion.div style={{ x: tipX, width: TIP_W }} className="-translate-y-1/2 rounded-xl border border-white/10 bg-slate-800/95 p-3 shadow-xl shadow-black/40 backdrop-blur">
                    <p className="text-xs font-medium text-slate-400">{range.label(index)}</p>
                    <p className="mt-1 flex items-center justify-between gap-2 text-sm text-slate-300">
                      <span className="flex items-center gap-1.5">
                        <span className="h-2 w-2 rounded-full bg-lime-300" />
                        Now
                      </span>
                      <span className="font-semibold tabular-nums text-white">{money(value)}</span>
                    </p>
                    <p className="mt-0.5 flex items-center justify-between gap-2 text-sm text-slate-300">
                      <span className="flex items-center gap-1.5">
                        <span className="h-2 w-2 rounded-full border border-dashed border-slate-400" />
                        Before
                      </span>
                      <span className="tabular-nums">{money(before)}</span>
                    </p>
                    <div className="mt-2 h-1 overflow-hidden rounded-full bg-white/10">
                      <motion.div
                        className="h-full origin-left rounded-full bg-lime-300"
                        animate={{ scaleX: Math.min(1, Math.max(0.06, (value - lo) / (hi - lo))) }}
                        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 200, damping: 24 }}
                      />
                    </div>
                  </motion.div>
                </motion.div>
              </motion.div>
            </div>
            <div aria-hidden="true" className="mt-3 flex justify-between text-xs text-slate-500">
              {range.ticks.map((t) => (
                <span key={t}>{t}</span>
              ))}
            </div>
          </div>
        </div>

        <dl className="mt-7 grid grid-cols-3 gap-2 border-t border-white/10 pt-6 sm:gap-4">
          {tiles.map((t) => (
            <div key={t.name} className="rounded-2xl bg-white/[0.04] px-3 py-3 sm:px-4">
              <dt className="text-xs font-medium text-slate-400 sm:text-sm">{t.name}</dt>
              <dd className="mt-1 text-base font-semibold tabular-nums text-white sm:text-xl">{t.value}</dd>
            </div>
          ))}
        </dl>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/revenue-dashboard-with-self-drawing-line-chart-and-scrubbing-crosshair.json

When to use it

How to customize it

Accessibility

Questions

How does the line draw itself in?

The SVG sits inside a wrapper whose `clip-path` animates from `inset(0 100% 0 0)` to `inset(0 0 0 0)`. That reveals the line, its glow and the gradient fill together from left to right, and it works even though the SVG is stretched with `preserveAspectRatio="none"`.

How is the crosshair kept smooth?

The pointer position is turned into the nearest data index. The pixel position of that point feeds two `useSpring` values, and the line, dot and tooltip are moved with transforms only, so nothing is laid out again while you scrub.

How do I plug in real data?

Replace the `walk` calls in `ranges` with arrays from your API. If your series have different lengths, resample them to `N` points first, or change `N` to match and keep all ranges equal.

Why does the tooltip switch sides?

The component measures the plot with a `ResizeObserver`. When the point plus the tooltip width would pass the right edge, the tooltip offset springs to the left of the crosshair instead.

Can I turn off the self demo?

Yes. Start the `auto` state as `false`. The demo also stops when the pointer or keyboard takes over and returns a moment after they leave, and it is paused while the chart is off screen.

Related: Stat Tiles with Sparkline Trends, Hero with Live Updating Dashboard Mock, Count Up Stats Row with Rolling Digits.

More dashboard or all free UI components. Start for free to have a full site built around it.

More in Dashboard

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap