For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Scatter Chart Card 01

Compare groups across two measures, with optional bubbles.

Usage

Pass groups of points with x and y values. Add z to size bubbles, or set bubble={false} to use equal dots. Set tiles={false} for a compact legend. The optional ranges prop supplies alternate datasets for the menu; popupClassName scopes the theme to its popup.

Source

"use client";

import { RiCalendarLine, RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";

export interface ScatterPoint {
  x: number;
  y: number;
  z?: number;
  label?: string;
}
export interface ScatterSeries {
  label: string;
  points: ScatterPoint[];
  color?: string;
}
export interface ScatterRange {
  label: string;
  series: ScatterSeries[];
}

const colors = [
  "var(--revenue-chart-active, var(--chart-1))",
  "var(--primary)",
  "var(--chart-5)",
];
const compact = (n: number) =>
  n >= 1000 ? `${Number((n / 1000).toFixed(1))}k` : String(Math.round(n));

const average = (points: ScatterPoint[]) =>
  points.length
    ? points.reduce((sum, point) => sum + point.y, 0) / points.length
    : 0;
const axisMax = (value: number) => {
  const scale = 10 ** Math.floor(Math.log10(value));
  return Math.ceil(value / scale) * scale;
};
// The y axis has three intervals, so round its maximum up to a multiple of
// three nice steps (15 for a value of 14, with ticks 0, 5, 10, 15).
const axisMaxThirds = (value: number) => {
  const raw = value / 3;
  const scale = 10 ** Math.floor(Math.log10(raw));
  const unit = [1, 2, 5, 10].find((n) => n * scale >= raw) ?? 10;
  return unit * scale * 3;
};

/** A number that rolls to its value without re-rendering anything around it. */
function AnimatedValue({
  value,
  format,
}: {
  value: number;
  format: (value: number) => string;
}) {
  const ref = useCountUp<HTMLSpanElement>(value, { format, precision: 2 });
  return <span ref={ref}>{format(value)}</span>;
}

export function ScatterChartCard({
  title,
  series,
  ranges,
  axisLabels = ["X", "Y"],
  bubble,
  tiles = true,
  change,
  format = (n) => Math.round(n).toLocaleString(),
  formatX = compact,
  popupClassName,
  className,
}: {
  title: string;
  series: ScatterSeries[];
  ranges?: ScatterRange[];
  axisLabels?: [string, string];
  bubble?: boolean;
  tiles?: boolean;
  change?: string;
  format?: (value: number) => string;
  formatX?: (value: number) => string;
  popupClassName?: string;
  className?: string;
}) {
  const [range, setRange] = React.useState(0);
  // The plot is drawn at the card's real width, so its text keeps its size
  // instead of shrinking with a scaled viewBox.
  const plotRef = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(448);
  React.useEffect(() => {
    const node = plotRef.current;
    if (!node) {
      return;
    }
    const measure = () => setWidth(Math.max(240, Math.round(node.clientWidth)));
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(node);
    return () => observer.disconnect();
  }, []);
  const plotL = 40;
  const plotR = width - 20;
  const plotW = plotR - plotL;
  const step = plotW / 4;
  const [active, setActive] = React.useState<{
    series: number;
    point: number;
  } | null>(null);
  const data = ranges?.[range]?.series ?? series;
  const all = data.flatMap((group) => group.points);
  const selected = active && data[active.series]?.points[active.point];
  const maxX = Math.max(1, ...all.map((point) => point.x));
  const maxY = Math.max(1, ...all.map((point) => point.y));
  const xMax = axisMax(maxX);
  const yMax = axisMaxThirds(maxY);
  const maxZ = Math.max(1, ...all.map((point) => point.z ?? 0));
  const showBubbles = bubble ?? all.some((point) => point.z != null);
  return (
    <Reveal
      variant="card"
      render={
        <Card variant="muted" className={cn("w-full min-w-0", className)} />
      }
    >
      <CardHeader>
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <p className="truncate text-sm leading-5 font-medium text-muted-foreground">
              {selected?.label ?? title}
            </p>
            <div className="flex items-center gap-2">
              <p className="text-2xl leading-8.5 font-medium tabular-nums">
                <AnimatedValue
                  value={selected?.y ?? average(all)}
                  format={format}
                />
              </p>
              {change && !selected && (
                <Badge variant="counter" tone="lime">
                  {change}
                </Badge>
              )}
            </div>
          </div>
          {ranges && ranges.length > 0 && (
            <DropdownMenu>
              <DropdownMenuTrigger
                render={<Button variant="outline" size="sm" />}
              >
                <RiCalendarLine data-icon="inline-start" />
                {ranges[range]?.label}
                <RiArrowDownSLine
                  data-icon="inline-end"
                  className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
                />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className={popupClassName}>
                <DropdownMenuGroup>
                  {ranges.map((option, index) => (
                    <DropdownMenuItem
                      key={option.label}
                      onClick={() => {
                        setRange(index);
                        setActive(null);
                      }}
                    >
                      {option.label}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          )}
        </div>
      </CardHeader>
      <CardContent ref={plotRef} onMouseLeave={() => setActive(null)}>
        <svg
          viewBox={`-8 0 ${String(width + 8)} 180`}
          className="h-auto w-full overflow-visible"
          role="group"
          aria-label={`${title}: ${axisLabels[0]} by ${axisLabels[1]}`}
        >
          <text x="0" y="173" fill="var(--muted-foreground)" fontSize="11">
            {axisLabels[1]}
          </text>
          <text
            x={plotR}
            y="173"
            textAnchor="end"
            fill="var(--muted-foreground)"
            fontSize="11"
          >
            {axisLabels[0]}
          </text>
          {[0, 1, 2, 3].map((i) => (
            <g key={`y${i}`}>
              <line
                x1="40"
                x2={plotR}
                y1={130 - i * 38}
                y2={130 - i * 38}
                stroke="var(--border)"
                strokeDasharray="2 4"
              />
              <text
                x="33"
                y={133 - i * 38}
                textAnchor="end"
                fill="var(--muted-foreground)"
                fontSize="11"
              >
                {compact((yMax * i) / 3)}
              </text>
            </g>
          ))}
          {[0, 1, 2, 3, 4].map((i) => (
            <g key={`x${i}`}>
              <line
                x1={plotL + i * step}
                x2={plotL + i * step}
                y1="16"
                y2="130"
                stroke="var(--border)"
                strokeDasharray="2 4"
              />
              <text
                x={plotL + i * step}
                y="152"
                textAnchor="middle"
                fill="var(--muted-foreground)"
                fontSize="11"
              >
                {formatX((xMax * i) / 4)}
              </text>
            </g>
          ))}
          {data.map((group, si) =>
            group.points.map((point, pi) => (
              <circle
                key={`${si}-${pi}`}
                cx={plotL + (Math.max(0, point.x) / xMax) * plotW}
                cy={130 - (Math.max(0, point.y) / yMax) * 114}
                r={
                  showBubbles
                    ? 6 + Math.sqrt(Math.max(0, point.z ?? 0) / maxZ) * 8
                    : 4
                }
                fill={group.color ?? colors[si % colors.length]}
                stroke={group.color ?? colors[si % colors.length]}
                strokeWidth="1.5"
                fillOpacity="0.85"
                opacity={active && active.series !== si ? 0.25 : 1}
                tabIndex={0}
                onMouseDown={(event) => event.preventDefault()}
                role="img"
                aria-label={`${point.label ?? group.label}: ${axisLabels[0]} ${formatX(point.x)}, ${axisLabels[1]} ${format(point.y)}`}
                onMouseEnter={() => setActive({ series: si, point: pi })}
                onFocus={() => setActive({ series: si, point: pi })}
                onBlur={() => setActive(null)}
                className="scatter-chart-card-01-point-in origin-center [transform-box:fill-box] outline-none transition-opacity duration-150 focus-visible:stroke-ring focus-visible:stroke-2 motion-reduce:transition-none"
              />
            ))
          )}
          {selected && active && (
            <circle
              key={`${active.series}-${active.point}`}
              cx={plotL + (Math.max(0, selected.x) / xMax) * plotW}
              cy={130 - (Math.max(0, selected.y) / yMax) * 114}
              r={
                showBubbles
                  ? 6 + Math.sqrt(Math.max(0, selected.z ?? 0) / maxZ) * 8
                  : 4
              }
              fill={
                data[active.series]?.color ??
                colors[active.series % colors.length]
              }
              pointerEvents="none"
              aria-hidden
              className="scatter-chart-card-01-pulse origin-center [transform-box:fill-box]"
            />
          )}
        </svg>
      </CardContent>
      <CardContent>
        <div
          className={cn(
            "flex flex-wrap gap-2",
            !tiles && "justify-center gap-4"
          )}
        >
          {data.map((group, i) => (
            <div
              key={group.label}
              className={cn(
                "min-w-0 text-xs",
                tiles
                  ? "min-w-0 flex-[1_1_120px] rounded-[8px] bg-card px-3 py-2"
                  : ""
              )}
            >
              <div
                className={cn(
                  "flex items-center gap-1.5",
                  !tiles && "flex-wrap"
                )}
              >
                <svg
                  viewBox="0 0 10 10"
                  className="size-2.5 shrink-0"
                  aria-hidden
                >
                  <rect
                    width="10"
                    height="10"
                    rx="3"
                    fill={group.color ?? colors[i % colors.length]}
                  />
                </svg>
                <span className="min-w-0 break-words text-muted-foreground">
                  {group.label}
                  {tiles && " · avg"}
                </span>
                {!tiles && (
                  <span className="font-medium tabular-nums">
                    <AnimatedValue
                      value={average(group.points)}
                      format={format}
                    />
                  </span>
                )}
              </div>
              {tiles && (
                <div className="mt-1 font-medium tabular-nums">
                  <AnimatedValue
                    value={average(group.points)}
                    format={format}
                  />
                </div>
              )}
            </div>
          ))}
        </div>
      </CardContent>
    </Reveal>
  );
}