Sections

Dashboard Overview

A dashboard top section with breadcrumb header, stat cards, and a hand-built SVG area chart whose data tweens when the time range changes.

Install

npx shadcn@latest add @paragon/dashboard-overview

Also installs: button, stat-card, segmented-control

dashboard-overview.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ChevronRight, Download, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import { StatCard, type StatCardProps } from "@/registry/paragon/ui/stat-card";
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/registry/paragon/ui/segmented-control";

/* ------------------------------------------------------------------ */
/* Deterministic series (module scope — never Math.random in render). */
/* ------------------------------------------------------------------ */

const POINTS = 32;

function makeSeries(seed: number, base: number, drift: number, vol: number) {
  let s = seed;
  const rand = () => {
    s = (s * 9301 + 49297) % 233280;
    return s / 233280;
  };
  const out: number[] = [];
  let v = base;
  for (let i = 0; i < POINTS; i++) {
    v += drift + (rand() - 0.5) * vol;
    out.push(Math.max(80, Math.round(v)));
  }
  return out;
}

type RangeKey = "24h" | "7d" | "30d" | "90d";

const RANGES: Record<
  RangeKey,
  { data: number[]; ticks: string[]; pointLabel: (i: number) => string }
> = {
  "24h": {
    data: makeSeries(11, 1240, 4, 220),
    ticks: ["00:00", "06:00", "12:00", "18:00", "23:59"],
    pointLabel: (i) =>
      `${String(Math.floor((i * 24) / POINTS)).padStart(2, "0")}:${String(
        Math.round((((i * 24) / POINTS) % 1) * 60),
      ).padStart(2, "0")}`,
  },
  "7d": {
    data: makeSeries(23, 1105, 9, 260),
    ticks: ["Jun 30", "Jul 2", "Jul 4", "Jul 6"],
    pointLabel: (i) => {
      const days = ["Jun 30", "Jul 1", "Jul 2", "Jul 3", "Jul 4", "Jul 5", "Jul 6"];
      return days[Math.min(6, Math.floor((i * 7) / POINTS))];
    },
  },
  "30d": {
    data: makeSeries(47, 980, 14, 320),
    ticks: ["Jun 7", "Jun 17", "Jun 27", "Jul 6"],
    pointLabel: (i) => `Jun ${7 + Math.floor((i * 29) / POINTS)}`,
  },
  "90d": {
    data: makeSeries(83, 760, 21, 380),
    ticks: ["Apr 8", "May 8", "Jun 7", "Jul 6"],
    pointLabel: (i) => {
      const day = Math.floor((i * 89) / POINTS);
      if (day < 23) return `Apr ${8 + day}`;
      if (day < 54) return `May ${day - 22}`;
      if (day < 84) return `Jun ${day - 53}`;
      return `Jul ${day - 83}`;
    },
  },
};

const STATS: StatCardProps[] = [
  {
    label: "Deployments",
    value: "1,284",
    delta: "+18.2%",
    trend: "up",
    data: [22, 24, 23, 27, 26, 30, 29, 33, 35, 34, 38, 41],
  },
  {
    label: "Deploy success rate",
    value: "99.4%",
    delta: "+0.3%",
    trend: "up",
    data: [97.9, 98.2, 98.1, 98.6, 98.4, 98.9, 99.0, 98.8, 99.1, 99.2, 99.3, 99.4],
  },
  {
    label: "P95 build time",
    value: "3m 42s",
    delta: "−12.6%",
    trend: "up",
    data: [312, 305, 298, 301, 288, 276, 270, 262, 251, 240, 231, 222],
  },
  {
    label: "Active previews",
    value: "86",
    delta: "−4.4%",
    trend: "down",
    data: [104, 101, 98, 99, 95, 96, 92, 90, 91, 88, 87, 86],
  },
];

/* ------------------------------------------------------------------ */
/* Chart geometry                                                      */
/* ------------------------------------------------------------------ */

const W = 720;
const H = 220;
const PAD_Y = 14;

function scale(data: number[]) {
  const min = Math.min(...data);
  const max = Math.max(...data);
  const range = max - min || 1;
  const drawable = H - PAD_Y * 2;
  return (v: number, i: number) => ({
    x: (i / (POINTS - 1)) * W,
    y: PAD_Y + (1 - (v - min) / range) * drawable,
  });
}

function buildPaths(data: number[]) {
  const s = scale(data);
  const line = data
    .map((v, i) => {
      const { x, y } = s(v, i);
      return `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`;
    })
    .join(" ");
  return { line, area: `${line} L${W} ${H} L0 ${H} Z` };
}

function formatK(v: number) {
  return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(Math.round(v));
}

const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);

export interface DashboardOverviewProps extends React.ComponentProps<"section"> {
  /** Workspace name shown in the breadcrumb. */
  workspace?: string;
}

/**
 * A dashboard top section for a deploy platform: breadcrumb header with
 * actions, four stat cards, and a hand-built SVG area chart with a hover
 * crosshair. Changing the time range tweens the plotted values point-by-point
 * (rAF interpolation, ease-out, ~350ms) so the path morphs instead of
 * swapping.
 */
export function DashboardOverview({
  workspace = "acme-prod",
  className,
  ...props
}: DashboardOverviewProps) {
  const reducedMotion = useReducedMotion();
  const gradientId = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const [range, setRange] = React.useState<RangeKey>("7d");
  const [plotted, setPlotted] = React.useState<number[]>(RANGES["7d"].data);
  const [hover, setHover] = React.useState<number | null>(null);
  const frame = React.useRef<number>(0);
  const chartRef = React.useRef<HTMLDivElement>(null);

  // Latest plotted values, so a mid-flight range change retargets smoothly.
  const plottedRef = React.useRef(plotted);
  plottedRef.current = plotted;

  // Tween plotted values toward the selected range's data.
  React.useEffect(() => {
    const target = RANGES[range].data;
    cancelAnimationFrame(frame.current);
    if (reducedMotion) {
      setPlotted(target);
      return;
    }
    let start: number | null = null;
    const from = plottedRef.current.slice();
    const step = (now: number) => {
      if (start === null) start = now;
      const t = Math.min(1, (now - start) / 350);
      const e = easeOutCubic(t);
      setPlotted(from.map((f, i) => f + (target[i] - f) * e));
      if (t < 1) frame.current = requestAnimationFrame(step);
    };
    frame.current = requestAnimationFrame(step);
    return () => cancelAnimationFrame(frame.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [range, reducedMotion]);

  const paths = buildPaths(plotted);
  const target = RANGES[range];
  const min = Math.min(...target.data);
  const max = Math.max(...target.data);
  const total = target.data.reduce((a, b) => a + b, 0);
  const s = scale(plotted);

  const onPointerMove = (e: React.PointerEvent) => {
    const rect = chartRef.current?.getBoundingClientRect();
    if (!rect) return;
    const frac = (e.clientX - rect.left) / rect.width;
    setHover(Math.max(0, Math.min(POINTS - 1, Math.round(frac * (POINTS - 1)))));
  };

  const hoverPoint = hover !== null ? s(plotted[hover], hover) : null;

  return (
    <section
      aria-label="Overview dashboard"
      className={cn("w-full", className)}
      {...props}
    >
      {/* Page header */}
      <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
        <div>
          <nav aria-label="Breadcrumb">
            <ol className="flex items-center gap-1 text-[13px] text-muted-foreground">
              <li>
                <a
                  href="#relay"
                  className="rounded-sm transition-colors duration-150 hover:text-foreground"
                >
                  Relay
                </a>
              </li>
              <li aria-hidden>
                <ChevronRight className="size-3.5" />
              </li>
              <li>
                <a
                  href="#workspace"
                  className="rounded-sm transition-colors duration-150 hover:text-foreground"
                >
                  {workspace}
                </a>
              </li>
              <li aria-hidden>
                <ChevronRight className="size-3.5" />
              </li>
              <li aria-current="page" className="text-foreground">
                Overview
              </li>
            </ol>
          </nav>
          <h1 className="mt-1.5 text-xl font-semibold tracking-tight">
            Overview
          </h1>
        </div>
        <div className="flex items-center gap-2.5">
          <Button variant="outline" size="sm">
            <Download aria-hidden />
            Export
          </Button>
          <Button size="sm">
            <Plus aria-hidden />
            New deployment
          </Button>
        </div>
      </header>

      {/* Stat cards */}
      <div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
        {STATS.map((stat) => (
          <StatCard key={stat.label} {...stat} />
        ))}
      </div>

      {/* Area chart */}
      <div className="mt-4 rounded-xl bg-card p-5 text-card-foreground shadow-border">
        <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
          <div>
            <h2 className="text-sm font-medium">Edge requests</h2>
            <p className="mt-0.5 text-xl font-semibold tracking-tight tabular-nums">
              {(total * 1000).toLocaleString("en-US")}
              <span className="ml-1.5 text-sm font-normal text-muted-foreground">
                total
              </span>
            </p>
          </div>
          <SegmentedControl
            value={range}
            onValueChange={(v) => setRange(v as RangeKey)}
            aria-label="Time range"
          >
            <SegmentedControlItem value="24h">24h</SegmentedControlItem>
            <SegmentedControlItem value="7d">7d</SegmentedControlItem>
            <SegmentedControlItem value="30d">30d</SegmentedControlItem>
            <SegmentedControlItem value="90d">90d</SegmentedControlItem>
          </SegmentedControl>
        </div>

        <div
          ref={chartRef}
          className="relative mt-5 touch-none select-none"
          onPointerMove={onPointerMove}
          onPointerLeave={() => setHover(null)}
        >
          {/* Y-axis labels */}
          <div
            aria-hidden
            className="pointer-events-none absolute inset-0 flex flex-col justify-between py-1 text-[11px] text-muted-foreground/70 tabular-nums"
          >
            <span>{formatK(max)} rps</span>
            <span>{formatK((min + max) / 2)}</span>
            <span>{formatK(min)}</span>
          </div>

          <svg
            viewBox={`0 0 ${W} ${H}`}
            preserveAspectRatio="none"
            className="block h-48 w-full sm:h-56"
            role="img"
            aria-label={`Edge requests over the last ${range}, ranging from ${formatK(min)} to ${formatK(max)} requests per second`}
          >
            <defs>
              <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="currentColor" stopOpacity="0.14" />
                <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
              </linearGradient>
            </defs>
            {/* Gridlines */}
            {[0.25, 0.5, 0.75].map((f) => (
              <line
                key={f}
                x1="0"
                x2={W}
                y1={PAD_Y + f * (H - PAD_Y * 2)}
                y2={PAD_Y + f * (H - PAD_Y * 2)}
                className="stroke-border"
                strokeWidth="1"
                strokeDasharray="3 5"
                vectorEffect="non-scaling-stroke"
              />
            ))}
            <g className="text-foreground">
              <path d={paths.area} fill={`url(#${gradientId})`} />
              <path
                d={paths.line}
                fill="none"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinejoin="round"
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
              />
            </g>
            {/* Crosshair */}
            {hoverPoint && (
              <g aria-hidden className="text-foreground">
                <line
                  x1={hoverPoint.x}
                  x2={hoverPoint.x}
                  y1="0"
                  y2={H}
                  className="stroke-foreground/25"
                  strokeWidth="1"
                  vectorEffect="non-scaling-stroke"
                />
                <circle
                  cx={hoverPoint.x}
                  cy={hoverPoint.y}
                  r="3.5"
                  fill="currentColor"
                  className="stroke-card"
                  strokeWidth="2"
                  vectorEffect="non-scaling-stroke"
                />
              </g>
            )}
          </svg>

          {/* Tooltip */}
          {hover !== null && (
            <div
              aria-hidden
              className="pointer-events-none absolute top-1 -translate-x-1/2 rounded-md bg-popover px-2.5 py-1.5 text-xs whitespace-nowrap shadow-overlay"
              style={{
                left: `clamp(3.25rem, ${(hover / (POINTS - 1)) * 100}%, calc(100% - 3.25rem))`,
              }}
            >
              <span className="font-medium tabular-nums">
                {Math.round(plotted[hover]).toLocaleString("en-US")} rps
              </span>
              <span className="ml-1.5 text-muted-foreground">
                {target.pointLabel(hover)}
              </span>
            </div>
          )}
        </div>

        {/* X-axis labels */}
        <div
          aria-hidden
          className="mt-2 flex justify-between text-[11px] text-muted-foreground/70 tabular-nums"
        >
          {target.ticks.map((t) => (
            <span key={t}>{t}</span>
          ))}
        </div>
      </div>
    </section>
  );
}