Basic Bubble Chart in MUI X Charts (JavaScript)

The same plot in 14 other libraries — JavaScript: Chart.js, D3.js, Apache ECharts, Highcharts; Python: Altair, Bokeh, lets-plot, Matplotlib, Plotly, plotnine, Pygal, Seaborn; R: ggplot2; Julia: Makie.jl. Compare all 15 side by side: Basic Bubble Chart in Python, R, Julia and JavaScript.

A bubble chart extending scatter plots by adding a third dimension through bubble size. Each point's position shows two variables (x, y) while the bubble size represents a third quantitative variable. This visualization is excellent for understanding relationships between three numerical variables simultaneously, revealing patterns that would be hidden in traditional 2D scatter plots.

Basic Bubble Chart rendered with MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// bubble-basic: Basic Bubble Chart
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 83/100 | Updated: 2026-09-27
import { ChartContainer } from "@mui/x-charts/ChartContainer";
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
import { ChartsXAxis } from "@mui/x-charts/ChartsXAxis";
import { ChartsYAxis } from "@mui/x-charts/ChartsYAxis";
import { useXScale, useYScale } from "@mui/x-charts/hooks";

const t = window.ANYPLOT_TOKENS;
const TITLE = "bubble-basic · javascript · muix · anyplot.ai";
// Explicit callout naming the diagonal the three archetypes trace - without
// it, the growth/margin correlation across clusters is only an unlabeled
// color pattern the viewer has to infer for themselves.
const SUBTITLE = "Growth leaders combine the strongest revenue growth and profit margin";
const TITLE_HEIGHT = 84;
const MARGIN = { top: 24, right: 210, bottom: 70, left: 90 };

// Semi-transparent fill composites differently over the two page backgrounds:
// the same alpha reads more saturated over #1A1A17 than over #FAF8F1, even
// though the underlying Imprint hex values never change. The effect is
// strongest for the palette's paler hues (lavender) — they already read
// brighter/more saturated against the near-black surface with no extra alpha
// at all, so a single flat dark-theme bump over-saturates lavender while
// barely helping the darker blue, which needs the most help staying visible.
// Scale the bump per hue instead, inversely to that hue's own luma.
const LIGHT_OPACITY = 0.52; // within spec's 0.5-0.7 overlap range
const DARK_OPACITY_MAX_BUMP = 0.16;
function relativeLuma(hex) {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

// --- Data (in-memory, deterministic LCG — no seeded RNG in the browser) -----
function lcg(seed) {
  let state = seed;
  return () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
}

function randomNormal(rand, mean, stdDev) {
  const u1 = Math.max(rand(), 1e-9);
  const u2 = rand();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return mean + z * stdDev;
}

// Three loose company archetypes — fast-growing leaders, steady mid-market
// challengers, and small niche players — so the cloud has real structure
// instead of a formless blob. Each archetype gets its own hue so the
// pattern reads immediately instead of hiding in a single-color cloud.
// Abstract group labels (no semantic color cue), so Imprint positions are
// used in canonical order 1->3, not cherry-picked.
// The community package has no bubble/z-size scatter mode (ZAxisConfig
// only maps z to colour), so bubbles are drawn as a custom SVG layer
// positioned via the chart's own scale hooks.
// Spreads are wide enough that the normal distributions rarely produce
// fully-fused bubbles on their own; fillOpacity plus the pageBg stroke
// (see spec: "use transparency to handle overlapping bubbles") cover the
// rest, so no separate collision-avoidance pass is needed.
const ARCHETYPES = [
  { name: "Growth leaders", growth: 24, margin: 19, share: 62, count: 15, xSpread: 9, ySpread: 7.5, color: t.palette[0] },
  { name: "Mid-market", growth: 12, margin: 10, share: 34, count: 20, xSpread: 7, ySpread: 6, color: t.palette[1] },
  { name: "Niche players", growth: 4, margin: 3, share: 14, count: 15, xSpread: 7, ySpread: 6, color: t.palette[2] },
];

// Per-archetype opacity (see the luma-scaled dark-theme bump above): the
// palest hue in play gets almost none of the bump, the darkest gets the most.
const archetypeLumas = ARCHETYPES.map((a) => relativeLuma(a.color));
const minArchetypeLuma = Math.min(...archetypeLumas);
const maxArchetypeLuma = Math.max(...archetypeLumas);
ARCHETYPES.forEach((a, i) => {
  // Growth leaders is the densest cluster (largest mean market share, so the
  // biggest bubbles land closest together) - the previous review flagged
  // heavy bubble-on-bubble fusion there. A thicker pageBg stroke keeps
  // individual boundaries readable without dropping opacity below the
  // spec's 0.5-0.7 floor.
  a.strokeWidth = i === 0 ? 3 : 2;
  if (window.ANYPLOT_THEME !== "dark") {
    a.opacity = LIGHT_OPACITY;
    return;
  }
  const paleness =
    maxArchetypeLuma === minArchetypeLuma
      ? 0
      : (archetypeLumas[i] - minArchetypeLuma) / (maxArchetypeLuma - minArchetypeLuma);
  a.opacity = LIGHT_OPACITY + DARK_OPACITY_MAX_BUMP * (1 - paleness);
});

const rand = lcg(42);

const companies = ARCHETYPES.flatMap((a, groupIndex) =>
  Array.from({ length: a.count }, (_, i) => ({
    id: `${groupIndex}-${i}`,
    x: Math.round(randomNormal(rand, a.growth, a.xSpread) * 10) / 10,
    y: Math.round(randomNormal(rand, a.margin, a.ySpread) * 10) / 10,
    size: Math.min(100, Math.max(10, Math.round(randomNormal(rand, a.share, 18)))),
    color: a.color,
    opacity: a.opacity,
    strokeWidth: a.strokeWidth,
  })),
);

const sizeValues = companies.map((d) => d.size);
const sizeMin = Math.min(...sizeValues);
const sizeMax = Math.max(...sizeValues);

// Scale bubbles by AREA, not radius — otherwise size differences read as
// far more extreme than the underlying data actually is. Capped tighter
// than the raw 10-100 domain would suggest so the densest cluster
// (growth ~20-30%, margin ~15-25%) stays legible instead of fusing together.
const MIN_RADIUS = 7;
const MAX_RADIUS = 38;
function radiusForSize(value) {
  const ratio = (value - sizeMin) / (sizeMax - sizeMin);
  return MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(Math.max(0, ratio));
}

// Legend geometry, computed once so the two legend blocks can be vertically
// centered against the chart instead of pinned at fixed heights that leave a
// visible gap of unused whitespace beneath them on tall canvases.
const LEGEND_GAP = 56;
const SIZE_LEGEND_VALUES = [
  Math.round(sizeMax / 10) * 10,
  Math.round((sizeMin + sizeMax) / 2 / 10) * 10,
  Math.round(sizeMin / 10) * 10,
];
const COLOR_LEGEND_HEIGHT = 20 + (ARCHETYPES.length - 1) * 24 + 12;
const SIZE_LEGEND_HEIGHT =
  20 + SIZE_LEGEND_VALUES.reduce((height, value) => height + radiusForSize(value) * 2 + 16, 0);

const xValues = companies.map((d) => d.x);
const yValues = companies.map((d) => d.y);
const xDomain = [Math.min(...xValues) - 4, Math.max(...xValues) + 4];
const yDomain = [Math.min(...yValues) - 4, Math.max(...yValues) + 4];

// --- Bubbles (reads the chart's live x/y scales via context hooks) ---------
function Bubbles() {
  const xScale = useXScale();
  const yScale = useYScale();
  return (
    <g>
      {companies.map((d) => (
        <circle
          key={d.id}
          cx={xScale(d.x)}
          cy={yScale(d.y)}
          r={radiusForSize(d.size)}
          fill={d.color}
          fillOpacity={d.opacity}
          stroke={t.pageBg}
          strokeWidth={d.strokeWidth}
        />
      ))}
    </g>
  );
}

// --- Color legend — names the three archetypes so the clustering reads as
// an insight instead of something the viewer has to discover unaided -------
function ColorLegend({ left, top }) {
  return (
    <g>
      <text x={left} y={top - 20} fontSize={14} fontWeight={600} fill={t.inkSoft}>
        Company archetype
      </text>
      {ARCHETYPES.map((a, i) => {
        const cy = top + i * 24;
        return (
          <g key={a.name}>
            <circle cx={left + 6} cy={cy} r={6} fill={a.color} fillOpacity={a.opacity} stroke={a.color} strokeWidth={1.5} />
            <text x={left + 20} y={cy} dominantBaseline="middle" fontSize={14} fill={t.inkSoft}>
              {a.name}
            </text>
          </g>
        );
      })}
    </g>
  );
}

// --- Size legend — three reference bubbles explain the size scaling --------
function SizeLegend({ left, top }) {
  let cursorY = top;

  return (
    <g>
      <text x={left} y={top - 20} fontSize={14} fontWeight={600} fill={t.inkSoft}>
        Market share index
      </text>
      {SIZE_LEGEND_VALUES.map((value) => {
        const r = radiusForSize(value);
        cursorY += r + 10;
        const cy = cursorY;
        cursorY += r + 16;
        return (
          <g key={value}>
            <circle
              cx={left + MAX_RADIUS}
              cy={cy}
              r={r}
              fill="none"
              stroke={t.inkSoft}
              strokeWidth={1.5}
            />
            <text
              x={left + MAX_RADIUS * 2 + 14}
              y={cy}
              dominantBaseline="middle"
              fontSize={14}
              fill={t.inkSoft}
            >
              {value}
            </text>
          </g>
        );
      })}
    </g>
  );
}

// --- Chart (default-exported component — the harness mounts it) -----------
export default function Chart() {
  const { width, height } = window.ANYPLOT_SIZE;
  const chartHeight = height - TITLE_HEIGHT;
  const margin = MARGIN;

  // Center the two-part legend against the chart height instead of pinning
  // it near the top, so it no longer leaves a tall unused gap underneath.
  const legendBlockHeight = COLOR_LEGEND_HEIGHT + LEGEND_GAP + SIZE_LEGEND_HEIGHT;
  const legendBlockTop = Math.max(44, (chartHeight - legendBlockHeight) / 2);
  const colorLegendTop = legendBlockTop + 20;
  const sizeLegendTop = legendBlockTop + COLOR_LEGEND_HEIGHT + LEGEND_GAP + 20;

  return (
    <div style={{ width, height }}>
      <div style={{ height: TITLE_HEIGHT, paddingLeft: 24, paddingTop: 14, color: t.ink }}>
        <div style={{ fontSize: 26, fontWeight: 500, lineHeight: "32px" }}>{TITLE}</div>
        <div style={{ fontSize: 15, fontWeight: 400, lineHeight: "20px", marginTop: 4, color: t.inkSoft }}>
          {SUBTITLE}
        </div>
      </div>
      <ChartContainer
        width={width}
        height={chartHeight}
        series={[]}
        skipAnimation
        margin={margin}
        xAxis={[
          {
            id: "growth",
            min: xDomain[0],
            max: xDomain[1],
            label: "Revenue growth rate (%)",
            labelStyle: { fontSize: 16 },
            tickLabelStyle: { fontSize: 14 },
          },
        ]}
        yAxis={[
          {
            id: "margin",
            min: yDomain[0],
            max: yDomain[1],
            label: "Profit margin (%)",
            labelStyle: { fontSize: 16 },
            tickLabelStyle: { fontSize: 14 },
          },
        ]}
      >
        <ChartsGrid horizontal vertical />
        <Bubbles />
        <ChartsXAxis axisId="growth" />
        <ChartsYAxis axisId="margin" />
        <ColorLegend left={width - margin.right + 24} top={colorLegendTop} />
        <SizeLegend left={width - margin.right + 24} top={sizeLegendTop} />
      </ChartContainer>
    </div>
  );
}

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/bubble-basic/muix/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "bubble-basic",
  "language": "javascript",
  "library": "muix",
  "page": "https://anyplot.ai/bubble-basic/javascript/muix",
  "hub": "https://anyplot.ai/bubble-basic",
  "code_json": "https://api.anyplot.ai/specs/bubble-basic/muix/code",
  "spec_json": "https://api.anyplot.ai/specs/bubble-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/javascript/muix/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/javascript/muix/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/javascript/muix/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/javascript/muix/plot-dark.html",
  "quality_score": 83.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Basic Bubble Chart on anyplot.ai.

Other implementations