Scatter Chart

Interactive scatter chart card.

Scatter

A bubble scatter plot.

Revenue per account

742

+6.8%
Starter270
Growth783
Scale1,388
function ScatterChartDemo() {
  return <ScatterChartCard series={SCATTER_SERIES} ranges={SCATTER_RANGES} />;
}
function ScatterChartDemo() {
  return <ScatterChartCard series={SCATTER_SERIES} ranges={SCATTER_RANGES} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/scatter-chart-card.json
npx shadcn@latest add https://boardcn.dev/r/scatter-chart-card.json

npm packages

  • recharts

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/charts/scatter-chart-card.tsx
"use client";

import { useMemo, useState } from "react";
import {
  CartesianGrid,
  ResponsiveContainer,
  Scatter,
  ScatterChart,
  Tooltip,
  XAxis,
  YAxis,
  ZAxis,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export interface ScatterChartPoint {
  x: number;
  y: number;
  /** Optional third measure. Its presence enables bubble sizing by default. */
  z?: number;
  /** Replaces the card title while this point is hovered. */
  label?: string;
}

export interface ScatterChartSeries {
  label: string;
  points: ScatterChartPoint[];
  /** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
  color?: string;
  /** Hover/outline color. A custom color is darkened when omitted. */
  activeColor?: string;
}

export interface ScatterChartRange {
  id: string;
  label: string;
  series: ScatterChartSeries[];
  /** Optional range-specific headline override. */
  headline?: number;
  /** Decimal change, for example .068 renders +6.8%. */
  delta?: number;
}

export interface ScatterChartCardProps {
  title?: string;
  series: ScatterChartSeries[];
  /** Captions below the plot, ordered [horizontal, vertical]. */
  axisLabels?: readonly [x: string, y: string];
  /** Force bubble sizing on/off. By default it follows the presence of z. */
  bubble?: boolean;
  headline?: number;
  /** Decimal change, for example .068 renders +6.8%. */
  delta?: number;
  /** Static range label. Supplying ranges renders an interactive picker. */
  range?: string;
  ranges?: ScatterChartRange[];
  defaultRange?: string;
  onRangeChange?: (id: string) => void;
  /** Formats y values in the header, legend, and tiles. */
  format?: (value: number) => string;
  /** Formats x values on the axis and in the hovered point label. */
  formatX?: (value: number) => string;
  /** Replaces the compact legend with per-series average tiles. */
  tiles?: boolean;
  className?: string;
}

type ResolvedTone = { color: string; activeColor: string };
type ActivePoint = { series: number; index: number };

const TONE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const BUBBLE_RANGE: [number, number] = [90, 620];
const formatNumber = (value: number) => value.toLocaleString("en-US");

function compact(value: number) {
  if (value < 1000) return `${Math.round(value)}`;
  return `${Math.round(value / 100) / 10}K`.replace(".0K", "K");
}

function average(points: ScatterChartPoint[]) {
  return points.length
    ? points.reduce((total, point) => total + point.y, 0) / points.length
    : 0;
}

function resolveTone(
  index: number,
  color?: string,
  activeColor?: string,
): ResolvedTone {
  if (color) {
    return {
      color,
      activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
    };
  }

  const tone = TONE_ORDER[index % TONE_ORDER.length];
  return {
    color: `var(--color-chart-${tone})`,
    activeColor: `var(--color-chart-${tone}-active)`,
  };
}

function describeDelta(delta: number) {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" as const };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? ("lime" as const) : ("rose" as const),
  };
}

function useAnimatedNumber(value: number) {
  const multiplier = 10 ** (
    Number.isInteger(value)
      ? 0
      : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
        ? 1
        : 2
  );
  return useCountUp(Math.round(value * multiplier), 320) / multiplier;
}

function ChartHeader({
  label,
  value,
  format,
  delta,
  hovering,
  fadeKey,
  range,
  ranges,
  rangeId,
  onRangeChange,
}: {
  label: string;
  value: number;
  format: (value: number) => string;
  delta?: number;
  hovering: boolean;
  fadeKey: string;
  range?: string;
  ranges?: ScatterChartRange[];
  rangeId?: string;
  onRangeChange: (id: string) => void;
}) {
  const displayedValue = useAnimatedNumber(value);
  const describedDelta = delta === undefined ? undefined : describeDelta(delta);

  return (
    <div className="flex w-full items-start justify-between gap-3">
      <div className="flex min-w-0 flex-1 flex-col gap-0.5">
        <p className="w-full truncate text-body-medium text-text-secondary">{label}</p>
        <div className="flex w-full items-center gap-2">
          <p
            key={fadeKey}
            className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
          >
            {format(displayedValue)}
          </p>
          {describedDelta && (
            <Chip color={describedDelta.color} className={hovering ? "invisible" : undefined}>
              {describedDelta.label}
            </Chip>
          )}
        </div>
      </div>
      <ChartRangeControl
        ranges={ranges}
        value={rangeId}
        onValueChange={onRangeChange}
        label={range}
      />
    </div>
  );
}

function ChartLegend({
  items,
  activeIndex,
}: {
  items: Array<{ label: string; color: string; value: string }>;
  activeIndex: number | null;
}) {
  return (
    <div className="flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-1 pb-1">
      {items.map((item, index) => (
        <div
          key={item.label}
          className={cx(
            "flex items-center gap-1.5 transition-opacity duration-200 ease-out",
            activeIndex !== null && activeIndex !== index && "opacity-50",
          )}
        >
          <span
            className="size-3 shrink-0 rounded-[4px]"
            style={{ backgroundColor: item.color }}
          />
          <span className="text-body-regular whitespace-nowrap text-text-secondary">
            {item.label}
          </span>
          <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
            {item.value}
          </span>
        </div>
      ))}
    </div>
  );
}

function ChartStatTiles({
  items,
  activeIndex,
  onActiveChange,
}: {
  items: Array<{
    label: string;
    value: string;
    color: string;
    activeColor: string;
  }>;
  activeIndex: number | null;
  onActiveChange: (index: number | null) => void;
}) {
  const remainder = items.length % 3;
  const hasOddCount = items.length % 2 === 1;

  return (
    <div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
      {items.map((item, index) => {
        const active = activeIndex === index;
        const inLastRow = remainder > 0 && index >= items.length - remainder;
        const desktopSpan = inLastRow ? 6 / remainder : 2;

        return (
          <div
            key={`${item.label}-${index}`}
            className={cx(
              "flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 transition-opacity duration-200 ease-out",
              hasOddCount && index === items.length - 1 ? "col-span-2" : "col-span-1",
              desktopSpan === 2 && "sm:col-span-2",
              desktopSpan === 3 && "sm:col-span-3",
              desktopSpan === 6 && "sm:col-span-6",
            )}
            style={{ opacity: activeIndex !== null && !active ? 0.4 : 1 }}
            onMouseEnter={() => onActiveChange(index)}
            onMouseLeave={() => onActiveChange(null)}
          >
            <div className="flex min-w-0 max-w-full items-center gap-1.5">
              <span
                className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out"
                style={{ backgroundColor: active ? item.activeColor : item.color }}
              />
              <span className="truncate text-body-regular text-text-secondary">{item.label}</span>
            </div>
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {item.value}
            </span>
          </div>
        );
      })}
    </div>
  );
}

/**
 * Correlation card with one mark per record. Hovering a point moves its x/y
 * values into the card header and fades the other cohorts. When a third
 * measure is present the dots become proportionally sized bubbles.
 */
export function ScatterChartCard({
  title = "Revenue per account",
  series,
  axisLabels,
  bubble,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatNumber,
  formatX = compact,
  tiles = false,
  className,
}: ScatterChartCardProps) {
  const [activePoint, setActivePoint] = useState<ActivePoint | null>(null);
  const availableRanges = ranges;
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ?? availableRanges?.[0];
  const currentSeries = selectedRange?.series ?? series;
  const currentHeadline = selectedRange?.headline ?? headline;
  const currentDelta = selectedRange?.delta ?? delta;
  const tones = useMemo(
    () => currentSeries.map((item, index) => resolveTone(index, item.color, item.activeColor)),
    [currentSeries],
  );
  const allPoints = currentSeries.flatMap((item) => item.points);
  const useBubbles = bubble ?? allPoints.some((point) => point.z !== undefined);
  const activeSeries = activePoint ? currentSeries[activePoint.series] : undefined;
  const point = activePoint ? activeSeries?.points[activePoint.index] : undefined;
  const headerLabel = point ? `${point.label ?? activeSeries?.label} · ${formatX(point.x)}` : title;
  const headerValue = point?.y ?? currentHeadline ?? Math.round(average(allPoints));
  const legendItems = currentSeries.map((item, index) => ({
    label: item.label,
    color: tones[index].color,
    value: format(Math.round(average(item.points))),
  }));

  function selectRange(id: string) {
    setActivePoint(null);
    setSelectedRangeId(id);
    onRangeChange?.(id);
  }

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        tiles && "h-auto",
        className,
      )}
    >
      <ChartHeader
        label={headerLabel}
        value={headerValue}
        format={format}
        delta={currentDelta}
        hovering={activePoint !== null}
        fadeKey={`${selectedRange?.id ?? ""}:${activePoint ? `${activePoint.series}:${activePoint.index}` : "idle"}`}
        range={range}
        ranges={availableRanges}
        rangeId={selectedRange?.id}
        onRangeChange={selectRange}
      />

      <div
        className={cx("min-h-0 w-full flex-1", tiles && "h-[196px] flex-none")}
      >
        <ResponsiveContainer width="100%" height="100%">
          <ScatterChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
            <CartesianGrid
              stroke="var(--color-chart-track)"
              strokeDasharray="4 4"
            />
            <XAxis
              type="number"
              dataKey="x"
              tickCount={5}
              tickFormatter={formatX}
              tickLine={false}
              axisLine={false}
              tickMargin={10}
              tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
            />
            <YAxis
              type="number"
              dataKey="y"
              width={48}
              tickCount={4}
              tickFormatter={compact}
              tickLine={false}
              axisLine={false}
              tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
            />
            {useBubbles && (
              <ZAxis type="number" dataKey="z" range={BUBBLE_RANGE} />
            )}
            <Tooltip content={() => null} cursor={false} isAnimationActive={false} />
            {currentSeries.map((item, seriesIndex) => {
              const dimmed = activePoint !== null && activePoint.series !== seriesIndex;
              return (
                <Scatter
                  key={item.label}
                  name={item.label}
                  data={item.points}
                  fill={tones[seriesIndex].color}
                  fillOpacity={dimmed ? 0.25 : 0.85}
                  stroke={tones[seriesIndex].activeColor}
                  strokeOpacity={dimmed ? 0.25 : 1}
                  strokeWidth={1.5}
                  className="cursor-default transition-opacity duration-200 ease-out"
                  onMouseEnter={(_, index) => setActivePoint({ series: seriesIndex, index })}
                  onMouseLeave={() => setActivePoint(null)}
                  isAnimationActive
                  animationDuration={450}
                />
              );
            })}
          </ScatterChart>
        </ResponsiveContainer>
      </div>

      {axisLabels && (
        <div className="flex w-full justify-between text-caption-1-medium text-text-tertiary">
          <span>{axisLabels[1]}</span>
          <span>{axisLabels[0]}</span>
        </div>
      )}

      {tiles ? (
        <ChartStatTiles
          items={currentSeries.map((item, index) => ({
            label: `${item.label} · avg`,
            value: format(Math.round(average(item.points))),
            color: tones[index].color,
            activeColor: tones[index].activeColor,
          }))}
          activeIndex={activePoint?.series ?? null}
          onActiveChange={(index) =>
            setActivePoint(index === null ? null : { series: index, index: 0 })
          }
        />
      ) : (
        <ChartLegend items={legendItems} activeIndex={activePoint?.series ?? null} />
      )}
    </section>
  );
}
"use client";

import { useMemo, useState } from "react";
import {
  CartesianGrid,
  ResponsiveContainer,
  Scatter,
  ScatterChart,
  Tooltip,
  XAxis,
  YAxis,
  ZAxis,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export interface ScatterChartPoint {
  x: number;
  y: number;
  /** Optional third measure. Its presence enables bubble sizing by default. */
  z?: number;
  /** Replaces the card title while this point is hovered. */
  label?: string;
}

export interface ScatterChartSeries {
  label: string;
  points: ScatterChartPoint[];
  /** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
  color?: string;
  /** Hover/outline color. A custom color is darkened when omitted. */
  activeColor?: string;
}

export interface ScatterChartRange {
  id: string;
  label: string;
  series: ScatterChartSeries[];
  /** Optional range-specific headline override. */
  headline?: number;
  /** Decimal change, for example .068 renders +6.8%. */
  delta?: number;
}

export interface ScatterChartCardProps {
  title?: string;
  series: ScatterChartSeries[];
  /** Captions below the plot, ordered [horizontal, vertical]. */
  axisLabels?: readonly [x: string, y: string];
  /** Force bubble sizing on/off. By default it follows the presence of z. */
  bubble?: boolean;
  headline?: number;
  /** Decimal change, for example .068 renders +6.8%. */
  delta?: number;
  /** Static range label. Supplying ranges renders an interactive picker. */
  range?: string;
  ranges?: ScatterChartRange[];
  defaultRange?: string;
  onRangeChange?: (id: string) => void;
  /** Formats y values in the header, legend, and tiles. */
  format?: (value: number) => string;
  /** Formats x values on the axis and in the hovered point label. */
  formatX?: (value: number) => string;
  /** Replaces the compact legend with per-series average tiles. */
  tiles?: boolean;
  className?: string;
}

type ResolvedTone = { color: string; activeColor: string };
type ActivePoint = { series: number; index: number };

const TONE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const BUBBLE_RANGE: [number, number] = [90, 620];
const formatNumber = (value: number) => value.toLocaleString("en-US");

function compact(value: number) {
  if (value < 1000) return `${Math.round(value)}`;
  return `${Math.round(value / 100) / 10}K`.replace(".0K", "K");
}

function average(points: ScatterChartPoint[]) {
  return points.length
    ? points.reduce((total, point) => total + point.y, 0) / points.length
    : 0;
}

function resolveTone(
  index: number,
  color?: string,
  activeColor?: string,
): ResolvedTone {
  if (color) {
    return {
      color,
      activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
    };
  }

  const tone = TONE_ORDER[index % TONE_ORDER.length];
  return {
    color: `var(--color-chart-${tone})`,
    activeColor: `var(--color-chart-${tone}-active)`,
  };
}

function describeDelta(delta: number) {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" as const };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? ("lime" as const) : ("rose" as const),
  };
}

function useAnimatedNumber(value: number) {
  const multiplier = 10 ** (
    Number.isInteger(value)
      ? 0
      : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
        ? 1
        : 2
  );
  return useCountUp(Math.round(value * multiplier), 320) / multiplier;
}

function ChartHeader({
  label,
  value,
  format,
  delta,
  hovering,
  fadeKey,
  range,
  ranges,
  rangeId,
  onRangeChange,
}: {
  label: string;
  value: number;
  format: (value: number) => string;
  delta?: number;
  hovering: boolean;
  fadeKey: string;
  range?: string;
  ranges?: ScatterChartRange[];
  rangeId?: string;
  onRangeChange: (id: string) => void;
}) {
  const displayedValue = useAnimatedNumber(value);
  const describedDelta = delta === undefined ? undefined : describeDelta(delta);

  return (
    <div className="flex w-full items-start justify-between gap-3">
      <div className="flex min-w-0 flex-1 flex-col gap-0.5">
        <p className="w-full truncate text-body-medium text-text-secondary">{label}</p>
        <div className="flex w-full items-center gap-2">
          <p
            key={fadeKey}
            className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
          >
            {format(displayedValue)}
          </p>
          {describedDelta && (
            <Chip color={describedDelta.color} className={hovering ? "invisible" : undefined}>
              {describedDelta.label}
            </Chip>
          )}
        </div>
      </div>
      <ChartRangeControl
        ranges={ranges}
        value={rangeId}
        onValueChange={onRangeChange}
        label={range}
      />
    </div>
  );
}

function ChartLegend({
  items,
  activeIndex,
}: {
  items: Array<{ label: string; color: string; value: string }>;
  activeIndex: number | null;
}) {
  return (
    <div className="flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-1 pb-1">
      {items.map((item, index) => (
        <div
          key={item.label}
          className={cx(
            "flex items-center gap-1.5 transition-opacity duration-200 ease-out",
            activeIndex !== null && activeIndex !== index && "opacity-50",
          )}
        >
          <span
            className="size-3 shrink-0 rounded-[4px]"
            style={{ backgroundColor: item.color }}
          />
          <span className="text-body-regular whitespace-nowrap text-text-secondary">
            {item.label}
          </span>
          <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
            {item.value}
          </span>
        </div>
      ))}
    </div>
  );
}

function ChartStatTiles({
  items,
  activeIndex,
  onActiveChange,
}: {
  items: Array<{
    label: string;
    value: string;
    color: string;
    activeColor: string;
  }>;
  activeIndex: number | null;
  onActiveChange: (index: number | null) => void;
}) {
  const remainder = items.length % 3;
  const hasOddCount = items.length % 2 === 1;

  return (
    <div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
      {items.map((item, index) => {
        const active = activeIndex === index;
        const inLastRow = remainder > 0 && index >= items.length - remainder;
        const desktopSpan = inLastRow ? 6 / remainder : 2;

        return (
          <div
            key={`${item.label}-${index}`}
            className={cx(
              "flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 transition-opacity duration-200 ease-out",
              hasOddCount && index === items.length - 1 ? "col-span-2" : "col-span-1",
              desktopSpan === 2 && "sm:col-span-2",
              desktopSpan === 3 && "sm:col-span-3",
              desktopSpan === 6 && "sm:col-span-6",
            )}
            style={{ opacity: activeIndex !== null && !active ? 0.4 : 1 }}
            onMouseEnter={() => onActiveChange(index)}
            onMouseLeave={() => onActiveChange(null)}
          >
            <div className="flex min-w-0 max-w-full items-center gap-1.5">
              <span
                className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out"
                style={{ backgroundColor: active ? item.activeColor : item.color }}
              />
              <span className="truncate text-body-regular text-text-secondary">{item.label}</span>
            </div>
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {item.value}
            </span>
          </div>
        );
      })}
    </div>
  );
}

/**
 * Correlation card with one mark per record. Hovering a point moves its x/y
 * values into the card header and fades the other cohorts. When a third
 * measure is present the dots become proportionally sized bubbles.
 */
export function ScatterChartCard({
  title = "Revenue per account",
  series,
  axisLabels,
  bubble,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatNumber,
  formatX = compact,
  tiles = false,
  className,
}: ScatterChartCardProps) {
  const [activePoint, setActivePoint] = useState<ActivePoint | null>(null);
  const availableRanges = ranges;
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ?? availableRanges?.[0];
  const currentSeries = selectedRange?.series ?? series;
  const currentHeadline = selectedRange?.headline ?? headline;
  const currentDelta = selectedRange?.delta ?? delta;
  const tones = useMemo(
    () => currentSeries.map((item, index) => resolveTone(index, item.color, item.activeColor)),
    [currentSeries],
  );
  const allPoints = currentSeries.flatMap((item) => item.points);
  const useBubbles = bubble ?? allPoints.some((point) => point.z !== undefined);
  const activeSeries = activePoint ? currentSeries[activePoint.series] : undefined;
  const point = activePoint ? activeSeries?.points[activePoint.index] : undefined;
  const headerLabel = point ? `${point.label ?? activeSeries?.label} · ${formatX(point.x)}` : title;
  const headerValue = point?.y ?? currentHeadline ?? Math.round(average(allPoints));
  const legendItems = currentSeries.map((item, index) => ({
    label: item.label,
    color: tones[index].color,
    value: format(Math.round(average(item.points))),
  }));

  function selectRange(id: string) {
    setActivePoint(null);
    setSelectedRangeId(id);
    onRangeChange?.(id);
  }

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        tiles && "h-auto",
        className,
      )}
    >
      <ChartHeader
        label={headerLabel}
        value={headerValue}
        format={format}
        delta={currentDelta}
        hovering={activePoint !== null}
        fadeKey={`${selectedRange?.id ?? ""}:${activePoint ? `${activePoint.series}:${activePoint.index}` : "idle"}`}
        range={range}
        ranges={availableRanges}
        rangeId={selectedRange?.id}
        onRangeChange={selectRange}
      />

      <div
        className={cx("min-h-0 w-full flex-1", tiles && "h-[196px] flex-none")}
      >
        <ResponsiveContainer width="100%" height="100%">
          <ScatterChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
            <CartesianGrid
              stroke="var(--color-chart-track)"
              strokeDasharray="4 4"
            />
            <XAxis
              type="number"
              dataKey="x"
              tickCount={5}
              tickFormatter={formatX}
              tickLine={false}
              axisLine={false}
              tickMargin={10}
              tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
            />
            <YAxis
              type="number"
              dataKey="y"
              width={48}
              tickCount={4}
              tickFormatter={compact}
              tickLine={false}
              axisLine={false}
              tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
            />
            {useBubbles && (
              <ZAxis type="number" dataKey="z" range={BUBBLE_RANGE} />
            )}
            <Tooltip content={() => null} cursor={false} isAnimationActive={false} />
            {currentSeries.map((item, seriesIndex) => {
              const dimmed = activePoint !== null && activePoint.series !== seriesIndex;
              return (
                <Scatter
                  key={item.label}
                  name={item.label}
                  data={item.points}
                  fill={tones[seriesIndex].color}
                  fillOpacity={dimmed ? 0.25 : 0.85}
                  stroke={tones[seriesIndex].activeColor}
                  strokeOpacity={dimmed ? 0.25 : 1}
                  strokeWidth={1.5}
                  className="cursor-default transition-opacity duration-200 ease-out"
                  onMouseEnter={(_, index) => setActivePoint({ series: seriesIndex, index })}
                  onMouseLeave={() => setActivePoint(null)}
                  isAnimationActive
                  animationDuration={450}
                />
              );
            })}
          </ScatterChart>
        </ResponsiveContainer>
      </div>

      {axisLabels && (
        <div className="flex w-full justify-between text-caption-1-medium text-text-tertiary">
          <span>{axisLabels[1]}</span>
          <span>{axisLabels[0]}</span>
        </div>
      )}

      {tiles ? (
        <ChartStatTiles
          items={currentSeries.map((item, index) => ({
            label: `${item.label} · avg`,
            value: format(Math.round(average(item.points))),
            color: tones[index].color,
            activeColor: tones[index].activeColor,
          }))}
          activeIndex={activePoint?.series ?? null}
          onActiveChange={(index) =>
            setActivePoint(index === null ? null : { series: index, index: 0 })
          }
        />
      ) : (
        <ChartLegend items={legendItems} activeIndex={activePoint?.series ?? null} />
      )}
    </section>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

ScatterChartCard

Correlation card with one mark per record. Hovering a point moves its x/y values into the card header and fades the other cohorts. When a third measure is present the dots become proportionally sized bubbles.

PropTypeDefaultDescription
seriesrequiredScatterChartSeries[]
axisLabelsreadonly [x: string, y: string]Captions below the plot, ordered [horizontal, vertical].
bubblebooleanForce bubble sizing on/off. By default it follows the presence of z.
classNamestring
defaultRangestring
deltanumberDecimal change, for example .068 renders +6.8%.
format(value: number) => string(value: number) => value.toLocaleString("en-US")Formats y values in the header, legend, and tiles.
formatX(value: number) => stringFormats x values on the axis and in the hovered point label.
headlinenumber
onRangeChange(id: string) => void
rangestringStatic range label. Supplying ranges renders an interactive picker.
rangesScatterChartRange[]
tilesbooleanfalseReplaces the compact legend with per-series average tiles.
titlestringRevenue per account