Distribution / Intermediate

Beeswarm Plot

Show every observation while separating overlapping values within groups.

Back to collection

Interactive Chart Preview

JAVASCRIPT
Live, interactive exampleChart.js

Chart.js Code Example

JAVASCRIPT
import Chart from 'chart.js/auto';

// Illustrative data used by the live preview.
const chartData = {
  "observationHeaders": [
    "Channel",
    "Minutes"
  ],
  "observations": [
    {
      "group": "Email",
      "value": 4
    },
    {
      "group": "Email",
      "value": 5
    },
    {
      "group": "Email",
      "value": 6
    },
    {
      "group": "Email",
      "value": 6
    },
    {
      "group": "Email",
      "value": 7
    },
    {
      "group": "Email",
      "value": 8
    },
    {
      "group": "Email",
      "value": 9
    },
    {
      "group": "Email",
      "value": 9
    },
    {
      "group": "Email",
      "value": 10
    },
    {
      "group": "Email",
      "value": 12
    },
    {
      "group": "Email",
      "value": 14
    },
    {
      "group": "Email",
      "value": 18
    },
    {
      "group": "Chat",
      "value": 2
    },
    {
      "group": "Chat",
      "value": 2
    },
    {
      "group": "Chat",
      "value": 3
    },
    {
      "group": "Chat",
      "value": 3
    },
    {
      "group": "Chat",
      "value": 4
    },
    {
      "group": "Chat",
      "value": 4
    },
    {
      "group": "Chat",
      "value": 5
    },
    {
      "group": "Chat",
      "value": 5
    },
    {
      "group": "Chat",
      "value": 6
    },
    {
      "group": "Chat",
      "value": 7
    },
    {
      "group": "Chat",
      "value": 8
    },
    {
      "group": "Chat",
      "value": 10
    }
  ],
  "datasets": []
};

function createConfig(chartData) {
    const observations = chartData.observations;
    const groups = [...new Set(observations.map(item => item.group))];
    const palette = ['#2563eb', '#0f766e', '#b45309', '#7c3aed', '#be185d'];
    const values = observations.map(item => item.value), low = Math.min(...values), high = Math.max(...values);
    const padding = high > low ? (high - low) * 0.08 : Math.max(1, Math.abs(low) * 0.05);
    const minimum = low - padding, maximum = high + padding;
    const counts = groups.map(group => observations.filter(item => item.group === group).length);
    // Merge forbidden offsets, then choose the nearest free position to the band center.
    function swarm(xPixels, band) {
        let radius = Math.min(4, band * 0.35);
        for (let attempt = 0; attempt < 64; attempt++) {
            const points = [];
            groups.forEach((group, groupIndex) => {
                const rows = observations.map((item, row) => ({ item, row })).filter(({ item }) => item.group === group).sort((a, b) => xPixels[a.row] - xPixels[b.row] || a.row - b.row);
                const placed = [];
                const distance = radius * 2.2;
                rows.forEach(({ row }) => {
                    const x = xPixels[row];
                    const intervals = placed.filter(point => Math.abs(point.x - x) < distance).map(point => {
                        const delta = Math.sqrt(Math.max(0, distance ** 2 - (point.x - x) ** 2));
                        return [point.offset - delta, point.offset + delta];
                    }).sort((a, b) => a[0] - b[0]);
                    const merged = [];
                    intervals.forEach(interval => {
                        const last = merged.at(-1);
                        if (last && interval[0] <= last[1])
                            last[1] = Math.max(last[1], interval[1]);
                        else
                            merged.push([...interval]);
                    });
                    const blocked = merged.find(interval => interval[0] < 0 && interval[1] > 0);
                    const offset = blocked ? (Math.abs(blocked[0]) <= Math.abs(blocked[1]) ? blocked[0] : blocked[1]) : 0;
                    placed.push({ x, offset });
                    points.push({ row, x, offset, group: groupIndex });
                });
            });
            if (points.every(point => Math.abs(point.offset) + radius <= band * 0.42))
                return { radius, points: points.sort((a, b) => a.row - b.row) };
            radius *= 0.75;
        }
        throw new Error('The swarm could not fit its drawing area.');
    }
    let pointRadius = 4;
    return {
        type: 'scatter', data: { datasets: [{ data: observations.map(item => ({ x: item.value, y: groups.indexOf(item.group) })), backgroundColor: observations.map(item => palette[groups.indexOf(item.group) % palette.length]), pointBorderWidth: 0, pointRadius: () => pointRadius, pointHoverRadius: () => pointRadius }] },
        plugins: [{ id: 'deterministic-swarm', afterDatasetsUpdate: chart => {
                    const band = Math.max(1, chart.chartArea.height) / groups.length;
                    const layout = swarm(values.map(value => chart.scales.x.getPixelForValue(value)), band);
                    pointRadius = layout.radius;
                    const elements = chart.getDatasetMeta(0).data;
                    layout.points.forEach(point => {
                        const element = elements[point.row];
                        element.x = point.x;
                        element.y = chart.scales.y.getPixelForValue(point.group) + point.offset;
                        element.options = { ...element.options, radius: layout.radius, hoverRadius: layout.radius, borderWidth: 0 };
                    });
                } }],
        options: { animation: false, responsive: true, maintainAspectRatio: false,
            scales: {
                x: { type: 'linear', min: minimum, max: maximum, title: { display: true, text: chartData.observationHeaders[1] } },
                y: { min: -0.5, max: groups.length - 0.5, reverse: true, afterBuildTicks: scale => { scale.ticks = groups.map((_, value) => ({ value })); }, ticks: { callback: value => groups[Number(value)] !== undefined ? `${groups[Number(value)].slice(0, 14)} (n=${counts[Number(value)]})` : '' }, title: { display: true, text: chartData.observationHeaders[0] }, grid: { display: false } },
            },
            plugins: { legend: { display: false }, title: { display: true, text: 'One Dot per Observation' }, tooltip: { callbacks: { label: item => { const observation = observations[item.dataIndex]; return `${observation.group}: ${observation.value} (row ${item.dataIndex + 1})`; } } } }, },
    };
}

const chart = new Chart(document.getElementById('myChart'), createConfig(chartData));

Preview and code follow the same library selection.

Run This Example

Install the package below in a browser project such as Vite. Put the copied code in a client JavaScript module and add this container to the page before the module runs.

npm install chart.js@4.5.1
<div style="height:320px"><canvas id="myChart"></canvas></div>

The example includes its sample data. When removing the chart, destroy or dispose of its instance and disconnect any resize observer.

Reading This Example

These illustrative response times contain 12 Email and 12 Chat observations, matching the Density and Violin examples. Email includes repeated values at 6 and 9 minutes; Chat repeats 2, 3, 4, and 5. Each repeat remains a separate dot. Email ranges from 4 to 18 minutes and Chat from 2 to 10.

All library tabs use these illustrative values. Rendering styles and library APIs differ, so use the table to compare exact numbers.

About this example & sample data
Sample values used in the chart
ChannelMinutes
Email4
Email5
Email6
Email6
Email7
Email8
Email9
Email9
Email10
Email12
Email14
Email18
Chat2
Chat2
Chat3
Chat3
Chat4
Chat4
Chat5
Chat5
Chat6
Chat7
Chat8
Chat10

READ THE CHART

Keep Every Observation Visible

A beeswarm plot places each observation on its measured coordinate and separates nearby dots across the other direction. It is useful when you want to inspect the values behind a distribution, especially in a small sample with repeated measurements.

01

Read the Measurement Along the Horizontal Axis

Each dot represents one row from the table. Its horizontal position is the supplied value on a shared numeric scale. The vertical band identifies its group. Movement above or below that group center only prevents overlap; it is not another measured variable. Repeated values remain separate dots at the same horizontal coordinate.

02

Keep Group Size Separate From Dot Size

All dots in one rendering have the same radius. The labels include the number of observations in each group. More dots can make a swarm look thicker, but thickness is not a normalized density estimate. Unlike the Violin Plot, this chart does not estimate a smooth population shape or add median marks.

03

Allow Room for Repeated Values

This example places values in ascending order, keeping table order for ties, and chooses the nearest available vertical position in the group. It uses no random jitter. If dots would exceed a group band, it reduces the common radius until every observation fits. Dense samples can produce very small dots; the table and copied data retain every row. Single observations and constant groups are valid.

04

Expect Layout Changes When the Chart Resizes

Spacing depends on the available drawing area. A different width can change vertical placement while each measured coordinate remains unchanged. Chart.js, ECharts, and Plotly recalculate placement when their container changes; the D3 SVG scales uniformly. Plotly keeps its axes fixed so zooming cannot invalidate dot spacing. All six examples use explicit deterministic placement. Seaborn draws those calculated positions with scatterplot instead of applying its own swarmplot algorithm.

Edit Chart Data