Split Violin Plot — MUI X Charts

A split violin plot displaying two distributions side-by-side within each violin, with each half representing a different group. Unlike standard violin plots that mirror the same distribution, split violins use the left and right halves to compare two conditions (such as before/after, male/female, or control/treatment) at each category level. This enables direct visual comparison of distribution shapes between paired groups.

Split Violin Plot rendered with MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// violin-split: Split Violin Plot
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-09-09
import { Box, Typography } from "@mui/material";
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 CAT_AXIS_ID = "category-axis";
const VAL_AXIS_ID = "value-axis";

// --- Data (in-memory, deterministic — tiny fixed-seed LCG) ------------------
function makeLcg(seed: number) {
  let state = seed >>> 0;
  return function next() {
    state = (Math.imul(1664525, state) + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rng = makeLcg(20260909);

function gaussian(mean: number, std: number) {
  let u1 = rng();
  while (u1 <= 1e-12) u1 = rng();
  const u2 = rng();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return mean + z * std;
}

function clamp(v: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, v));
}

const N_PER_GROUP = 140;
const SPLIT_GROUPS = ["Control group", "Experimental group"];

const CATEGORY_PARAMS = [
  {
    name: "Mathematics",
    control: { mean: 68, std: 11 },
    experimental: { mean: 74, std: 9 },
  },
  {
    name: "Science",
    control: { mean: 74, std: 9 },
    experimental: { mean: 80, std: 7 },
  },
  {
    name: "Language Arts",
    control: { mean: 76, std: 8 },
    experimental: { mean: 81, std: 7 },
  },
  {
    name: "History",
    control: { mean: 70, std: 10 },
    experimental: { mean: 76, std: 8 },
  },
];
const CATEGORIES = CATEGORY_PARAMS.map((c) => c.name);

const DATA: Record<string, Record<string, number[]>> = {};
CATEGORY_PARAMS.forEach(({ name, control, experimental }) => {
  DATA[name] = {
    // Only the physical floor (0) is clamped — a hard clamp at 100 would pile
    // up upper-tail samples on the boundary and flatten the violin's tip.
    "Control group": Array.from({ length: N_PER_GROUP }, () =>
      clamp(gaussian(control.mean, control.std), 0, Infinity),
    ),
    "Experimental group": Array.from({ length: N_PER_GROUP }, () =>
      clamp(gaussian(experimental.mean, experimental.std), 0, Infinity),
    ),
  };
});

const ALL_VALUES = CATEGORIES.flatMap((cat) =>
  SPLIT_GROUPS.flatMap((grp) => DATA[cat][grp]),
);
const RAW_MIN = Math.min(...ALL_VALUES);
const RAW_MAX = Math.max(...ALL_VALUES);
const PAD = (RAW_MAX - RAW_MIN) * 0.1;
const Y_MIN = Math.max(0, Math.floor(RAW_MIN - PAD));
// Test scores don't exceed 100 — cap the axis window there. The underlying
// data stays unclamped (see above) so the KDE tail decays smoothly into this
// boundary instead of piling up on it.
const Y_MAX = Math.min(100, Math.ceil(RAW_MAX + PAD));

const GRID_N = 220;
const GRID_Y = Array.from(
  { length: GRID_N },
  (_, i) => Y_MIN + ((Y_MAX - Y_MIN) * i) / (GRID_N - 1),
);

const GROUP_COLORS = [t.palette[0], t.palette[1]];

// --- KDE (Gaussian kernel, Silverman bandwidth) — draws the violin curves ---
function silvermanBandwidth(values: number[]) {
  const n = values.length;
  const mean = values.reduce((s, v) => s + v, 0) / n;
  const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1);
  const sigma = Math.sqrt(variance) || 1;
  return Math.max(0.9 * sigma * Math.pow(n, -0.2), 0.6);
}

function kdeCurve(values: number[], gridY: number[]) {
  const h = silvermanBandwidth(values);
  const n = values.length;
  const norm = 1 / (n * h * Math.sqrt(2 * Math.PI));
  return gridY.map((y) => {
    let sum = 0;
    for (let i = 0; i < n; i++) {
      const u = (y - values[i]) / h;
      sum += Math.exp(-0.5 * u * u);
    }
    return sum * norm;
  });
}

// Trims the near-zero density tails so each half pinches to the center spine
// instead of drawing a near-invisible sliver across the whole y-range.
function trimToSupport(density: number[], thresholdRatio: number) {
  const max = Math.max(...density);
  const threshold = max * thresholdRatio;
  let lo = 0;
  let hi = density.length - 1;
  while (lo < hi && density[lo] < threshold) lo++;
  while (hi > lo && density[hi] < threshold) hi--;
  lo = Math.max(0, lo - 1);
  hi = Math.min(density.length - 1, hi + 1);
  return { lo, hi, max };
}

function quartileStats(values: number[]) {
  const sorted = [...values].sort((a, b) => a - b);
  const quantile = (p: number) => {
    const pos = (sorted.length - 1) * p;
    const base = Math.floor(pos);
    const rest = pos - base;
    return sorted[base + 1] !== undefined
      ? sorted[base] + rest * (sorted[base + 1] - sorted[base])
      : sorted[base];
  };
  return { q1: quantile(0.25), median: quantile(0.5), q3: quantile(0.75) };
}

// Largest control-vs-experimental median gap across categories — drives the
// callout annotation that anchors the data-storytelling focal point.
const GAP_INFO = CATEGORIES.map((cat) => {
  const controlMedian = quartileStats(DATA[cat]["Control group"]).median;
  const experimentalMedian = quartileStats(
    DATA[cat]["Experimental group"],
  ).median;
  return {
    category: cat,
    gap: experimentalMedian - controlMedian,
  };
}).reduce((best, cur) => (Math.abs(cur.gap) > Math.abs(best.gap) ? cur : best));

// --- Custom-drawn split violins (community @mui/x-charts has no built-in
// violin mark — composed from ChartContainer's cartesian scales instead) ----
function Violins() {
  const xScale = useXScale(CAT_AXIS_ID);
  const yScale = useYScale(VAL_AXIS_ID);

  return (
    <>
      {CATEGORIES.map((cat) => {
        const bandStart = xScale(cat);
        if (bandStart === undefined) return null;
        const bandwidth = xScale.bandwidth();
        const cx = bandStart + bandwidth / 2;
        const halfWidthMax = bandwidth * 0.42;

        const halves = SPLIT_GROUPS.map((grp, gi) => {
          const values = DATA[cat][grp];
          const density = kdeCurve(values, GRID_Y);
          const { lo, hi, max } = trimToSupport(density, 0.01);
          const side = gi === 0 ? -1 : 1;
          const color = GROUP_COLORS[gi];

          const points: [number, number][] = [];
          for (let i = lo; i <= hi; i++) {
            const w = (density[i] / max) * halfWidthMax;
            points.push([cx + side * w, yScale(GRID_Y[i]) as number]);
          }
          const d =
            `M ${cx} ${points[0][1]} ` +
            points.map(([x, y]) => `L ${x} ${y}`).join(" ") +
            ` L ${cx} ${points[points.length - 1][1]} Z`;

          const { q1, median, q3 } = quartileStats(values);
          const markerX = cx + side * 7;
          // GRID_Y is ascending, so index `hi` is the higher score (top of
          // chart, smaller pixel y) and `lo` is the lower score (bottom).
          const yTop = yScale(GRID_Y[hi]) as number;
          const yBottom = yScale(GRID_Y[lo]) as number;

          return { grp, d, color, q1, median, q3, markerX, yTop, yBottom };
        });

        // Thin spine anchoring the two halves where they meet, spanning the
        // combined vertical extent of both violins for this category.
        const spineTop = Math.min(...halves.flatMap((h) => [h.yTop, h.yBottom]));
        const spineBottom = Math.max(
          ...halves.flatMap((h) => [h.yTop, h.yBottom]),
        );

        const isMaxGap = cat === GAP_INFO.category;

        return (
          <g key={cat}>
            <line
              x1={cx}
              x2={cx}
              y1={spineTop}
              y2={spineBottom}
              stroke={t.inkSoft}
              strokeWidth={1}
              opacity={0.4}
            />
            {halves.map(({ grp, d, color, q1, median, q3, markerX }) => (
              <g key={grp}>
                <path
                  d={d}
                  fill={color}
                  fillOpacity={0.75}
                  stroke={color}
                  strokeWidth={1.5}
                  strokeOpacity={0.95}
                  strokeLinejoin="round"
                />
                <line
                  x1={markerX}
                  x2={markerX}
                  y1={yScale(q1)}
                  y2={yScale(q3)}
                  stroke={t.pageBg}
                  strokeWidth={5}
                  strokeLinecap="round"
                  opacity={0.85}
                />
                <circle
                  cx={markerX}
                  cy={yScale(median)}
                  r={4.5}
                  fill={t.pageBg}
                  stroke={color}
                  strokeWidth={1.5}
                />
              </g>
            ))}
            {isMaxGap && (
              <text
                x={cx}
                y={spineTop - 12}
                textAnchor="middle"
                fontSize={13}
                fontWeight={700}
                fill={t.ink}
              >
                {`Largest gap: ${GAP_INFO.gap >= 0 ? "+" : ""}${GAP_INFO.gap.toFixed(1)} pt`}
              </text>
            )}
          </g>
        );
      })}
    </>
  );
}

// --- Chart (default-exported component — the harness mounts it) -----------
export default function Chart() {
  const W = window.ANYPLOT_SIZE.width;
  const H = window.ANYPLOT_SIZE.height;
  const TITLE_H = 46;
  const LEGEND_H = 34;
  const CHART_H = H - TITLE_H - LEGEND_H;

  return (
    <Box sx={{ width: W, height: H, display: "flex", flexDirection: "column" }}>
      <Typography
        sx={{
          height: TITLE_H,
          lineHeight: `${TITLE_H}px`,
          fontSize: 22,
          fontWeight: 700,
          textAlign: "center",
        }}
      >
        violin-split · javascript · muix · anyplot.ai
      </Typography>
      <Box
        sx={{
          height: LEGEND_H,
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          gap: 4,
        }}
      >
        {SPLIT_GROUPS.map((grp, i) => (
          <Box key={grp} sx={{ display: "flex", alignItems: "center", gap: 1 }}>
            <Box
              sx={{
                width: 16,
                height: 16,
                borderRadius: "3px",
                backgroundColor: GROUP_COLORS[i],
              }}
            />
            <Typography sx={{ fontSize: 16, color: "text.secondary" }}>
              {grp}
            </Typography>
          </Box>
        ))}
      </Box>
      <ChartContainer
        width={W}
        height={CHART_H}
        series={[]}
        skipAnimation
        margin={{ top: 36, right: 50, bottom: 74, left: 96 }}
        xAxis={[
          {
            id: CAT_AXIS_ID,
            scaleType: "band",
            data: CATEGORIES,
            label: "Subject",
            tickLabelStyle: { fontSize: 14 },
            labelStyle: { fontSize: 16, fontWeight: 600 },
          },
        ]}
        yAxis={[
          {
            id: VAL_AXIS_ID,
            scaleType: "linear",
            min: Y_MIN,
            max: Y_MAX,
            label: "Test score (points)",
            tickLabelStyle: { fontSize: 14 },
            labelStyle: { fontSize: 16, fontWeight: 600 },
          },
        ]}
      >
        <ChartsGrid horizontal />
        <Violins />
        <ChartsXAxis axisId={CAT_AXIS_ID} position="bottom" />
        <ChartsYAxis axisId={VAL_AXIS_ID} position="left" />
      </ChartContainer>
    </Box>
  );
}

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/violin-split/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": "violin-split",
  "language": "javascript",
  "library": "muix",
  "page": "https://anyplot.ai/violin-split/javascript/muix",
  "hub": "https://anyplot.ai/violin-split",
  "code_json": "https://api.anyplot.ai/specs/violin-split/muix/code",
  "spec_json": "https://api.anyplot.ai/specs/violin-split",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/violin-split/javascript/muix/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/violin-split/javascript/muix/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/violin-split/javascript/muix/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/violin-split/javascript/muix/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Split Violin Plot on anyplot.ai.

Other implementations