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.jsonnpx shadcn@latest add https://boardcn.dev/r/scatter-chart-card.jsonnpm 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.
"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.
| Prop | Type | Default | Description |
|---|---|---|---|
| seriesrequired | ScatterChartSeries[] | — | — |
| axisLabels | readonly [x: string, y: string] | — | Captions below the plot, ordered [horizontal, vertical]. |
| bubble | boolean | — | Force bubble sizing on/off. By default it follows the presence of z. |
| className | string | — | — |
| defaultRange | string | — | — |
| delta | number | — | Decimal 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) => string | — | Formats x values on the axis and in the hovered point label. |
| headline | number | — | — |
| onRangeChange | (id: string) => void | — | — |
| range | string | — | Static range label. Supplying ranges renders an interactive picker. |
| ranges | ScatterChartRange[] | — | — |
| tiles | boolean | false | Replaces the compact legend with per-series average tiles. |
| title | string | Revenue per account | — |