Scatter Chart

A composable time-series scatter chart with hover dimming, optional offset rings, jitter, and animated enter

Installation

pnpm dlx shadcn@latest add @bklit/scatter-chart

Usage

Build scatter charts by composing ScatterChart with one or more Scatter series, plus shared cartesian pieces (Grid or Background, XAxis, ChartTooltip).

Basic Example

import { ScatterChart, Scatter, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts";

const data = [
  { date: new Date("2025-01-01"), users: 1200 },
  { date: new Date("2025-02-01"), users: 1350 },
  { date: new Date("2025-03-01"), users: 1100 },
];

export default function SimpleScatter() {
  return (
    <ScatterChart data={data}>
      <Grid horizontal />
      <Scatter dataKey="users" />
      <XAxis />
      <ChartTooltip />
    </ScatterChart>
  );
}

Multiple Series

Series colors default to the chart palette (--chart-1 … --chart-5) in child order:

<ScatterChart data={data}>
  <Grid horizontal />
  <Scatter dataKey="sessions" />
  <Scatter dataKey="conversions" />
  <XAxis />
  <ChartTooltip />
</ScatterChart>

Offset Ring

Points render as solid dots by default. Rings are opt-in — set strokeWidth to draw an outer ring separated from the fill by ringGap:

<Scatter dataKey="users" radius={6} strokeWidth={2} ringGap={2} />

Hover Interaction

While the crosshair is active, non-active points fade to inactiveOpacity (default 0.3, matching other charts' faded state) and the active point scales up. Blur is opt-in via inactiveMode="blur":

// Default — opacity only
<Scatter dataKey="users" />

// Opacity + blur
<Scatter dataKey="users" inactiveMode="blur" inactiveBlur={2} />

// No dimming
<Scatter dataKey="users" fadeOnHover={false} />

Scatter with Jitter

When many rows share the same date (a strip plot), points stack on top of each other. Pass jitter to ScatterChart to spread them horizontally (and optionally vertically). Offsets are seeded and deterministic — no Math.random — so points never jump between renders or SSR/hydration. Hover snaps to the nearest jittered point, the tooltip shows the original values, and ChartTooltip hides its crosshair line by default (it would mark a date, not the hovered point — pass showCrosshair to opt back in).

The example below plots lines changed per merged PR, month by month: Fastlane PRs are auto-merged and always stay under 500 LOC (dashed line), while human-reviewed PRs spread much wider with a few large outliers. formatXLabel turns the axis into short month names and YAxis formatValue keeps LOC ticks compact.

import {
  ChartTooltip,
  Grid,
  Scatter,
  ScatterChart,
  TooltipContent,
  XAxis,
  YAxis,
} from "@bklitui/ui/charts";

// Seeded Park–Miller PRNG — identical on server and client (no Math.random)
function seededRandom(seed: number) {
  let state = seed;
  return () => {
    state = (state * 48_271) % 2_147_483_647;
    return state / 2_147_483_647;
  };
}

const random = seededRandom(42);
const chartData: { date: Date; fastlane?: number; human?: number }[] = [];
for (let month = 0; month < 12; month++) {
  const date = new Date(2025, month, 1);
  // Fastlane: auto-merged PRs, always < 500 LOC, skewed small
  const fastlaneCount = 15 + Math.floor(random() * 16);
  for (let i = 0; i < fastlaneCount; i++) {
    const r = random();
    chartData.push({ date, fastlane: Math.round(10 + r * r * 480) });
  }
  // Human reviewed: broader spread with a few large outliers
  const humanCount = 15 + Math.floor(random() * 16);
  for (let i = 0; i < humanCount; i++) {
    const r = random();
    const isOutlier = random() < 0.06;
    chartData.push({
      date,
      human: Math.round(isOutlier ? 1500 + r * 1000 : 40 + r * Math.sqrt(r) * 1160),
    });
  }
}

const series = {
  fastlane: { label: "Fastlane (auto-merged)", color: "var(--chart-1)" },
  human: { label: "Human reviewed", color: "var(--chart-2)" },
} as const;

const shortMonthFmt = new Intl.DateTimeFormat("en-US", { month: "short" });
const monthYearFmt = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" });
const locFmt = new Intl.NumberFormat("en-US");
const formatMonth = (date: Date) => shortMonthFmt.format(date);
const formatLoc = (value: number) => (value >= 1000 ? `${value / 1000}k` : String(value));

function PrTooltip({ point }: { point: Record<string, unknown> }) {
  const rows = (["fastlane", "human"] as const).flatMap((key) => {
    const value = point[key];
    return typeof value === "number"
      ? [{ color: series[key].color, label: series[key].label, value: `${locFmt.format(value)} LOC` }]
      : [];
  });
  return <TooltipContent rows={rows} title={monthYearFmt.format(point.date as Date)} />;
}

<ScatterChart
  data={chartData}
  formatXLabel={formatMonth}
  jitter={{ x: 0.6 }}
  margin={{ top: 8, right: 8, bottom: 40, left: 56 }}
>
  <Grid horizontal highlightRowValues={[500]} highlightRowStrokeDasharray="4,4" />
  <Scatter dataKey="fastlane" fill={series.fastlane.color} radius={3} />
  <Scatter dataKey="human" fill={series.human.color} radius={3} />
  <YAxis formatValue={formatLoc} />
  <XAxis numTicks={12} />
  <ChartTooltip content={({ point }) => <PrTooltip point={point} />} />
</ScatterChart>

jitter accepts:

  • true — spread across 0.35 of the band step horizontally
  • a number — horizontal spread as a fraction of the band step (0–1)
  • { x?: number; y?: number; seed?: number } — x as a band-step fraction, y as a maximum vertical offset in px (±), seed to pick a different (still deterministic) pattern

The band step is the smallest gap between adjacent distinct x values; the plot pads its edges so jittered points stay inside.

Y Gradient

Color dots by vertical position with a chart-space gradient — lower values toward red, higher toward green. Works best with the default solid fills (no rings):

<Scatter dataKey="users" strokeWidth={0} yGradient />

// Custom stops
<Scatter
  dataKey="users"
  strokeWidth={0}
  yGradient={{ from: "var(--color-red-500)", to: "var(--color-emerald-500)" }}
/>

Props

ScatterChart

PropTypeDefaultDescription
dataRecord<string, unknown>[]requiredRows with a date (or xDataKey) and numeric series fields
xDataKeystring"date"Field used for the time x-axis
marginPartial<Margin>40 all sidesChart margins
animationDurationnumber1100Enter animation duration (ms)
enterTransitionTransitionline-chart defaultMotion tween for enter
aspectRatiostring"2 / 1"Container aspect ratio
jitterboolean | number | { x?: number; y?: number; seed?: number }—Deterministic spread for overlapping points (strip plot). x = fraction of band step, y = px. Hover snaps to the nearest point and ChartTooltip hides its crosshair by default
formatXLabel(date: Date) => string"Jan 1" styleX-axis tick and date-pill labels (e.g. short month names)

Scatter

PropTypeDefaultDescription
dataKeystringrequiredY value field
yAxisIdstring | number"left"Y-scale group for biaxial charts (pair with YAxis)
fillstringseries paletteInner dot fill
strokestringsame as fillOuter ring color
strokeWidthnumber0Outer ring width — rings are opt-in (e.g. 2)
ringGapnumber2Gap between fill and ring (px)
radiusnumber5Inner dot radius (px)
fadeOnHoverbooleantrueDim non-active points on hover
inactiveOpacitynumber0.3Opacity for dimmed points
inactiveMode"opacity" | "blur""opacity"De-emphasis style; "blur" adds inactiveBlur on top of opacity
inactiveBlurnumber2Blur (px) for dimmed points when inactiveMode="blur"
showActiveHighlightbooleantrueScale up the active point
yGradientboolean | { from?: string; to?: string }—Color dots by y-position (default red → green)

Shared Components

Use the same cartesian building blocks as LineChart:

  • Grid — horizontal/vertical grid lines
  • Background — pattern fill when grid lines are hidden (see gallery)
  • YAxis — value labels (single shared scale; see Y Axis)
  • XAxis — date labels with crosshair fade
  • ChartTooltip — crosshair, date pill, and series rows