Distribution / Intermediate
Violin Plot
Compare distribution shapes and sample medians across groups.
Interactive Chart Preview
JAVASCRIPTEdit the column names and values to update your chart. Changes stay in this page until you reload.
| Row# | Observation Group | Individual Value | Remove row |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 3 | |||
| 4 | |||
| 5 | |||
| 6 | |||
| 7 | |||
| 8 | |||
| 9 | |||
| 10 | |||
| 11 | |||
| 12 | |||
| 13 | |||
| 14 | |||
| 15 | |||
| 16 | |||
| 17 | |||
| 18 | |||
| 19 | |||
| 20 | |||
| 21 | |||
| 22 | |||
| 23 | |||
| 24 |
Enter individual observations, including repeats. Each group needs two different values for its Scott bandwidth. Violins have equal maximum widths. Dark marks show sample medians. Incomplete groups remain drafts.
Chart.js Code Example
JAVASCRIPTimport 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 curves = groups.map((group, index) => {
const values = observations.filter(item => item.group === group).map(item => item.value);
const n = values.length, mean = values.reduce((sum, value) => sum + value, 0) / n;
const h = Math.hypot(...values.map(value => value - mean)) / Math.sqrt(n - 1) * n ** -0.2;
const lower = Math.min(...values) - 3 * h, upper = Math.max(...values) + 3 * h;
const points = Array.from({ length: 257 }, (_, point) => {
const x = lower + (upper - lower) * point / 256;
const y = values.reduce((sum, value) => sum + Math.exp(-0.5 * ((x - value) / h) ** 2), 0) / (n * h * Math.sqrt(2 * Math.PI));
return { x, y };
});
const sorted = [...values].sort((a, b) => a - b);
const median = (sorted[Math.floor((n - 1) / 2)] + sorted[Math.floor(n / 2)]) / 2;
const peak = Math.max(...points.map(point => point.y));
const halfMedian = Math.min(0.4, 0.4 * values.reduce((sum, value) => sum + Math.exp(-0.5 * ((median - value) / h) ** 2), 0) / (n * h * Math.sqrt(2 * Math.PI)) / peak);
const polygon = [...points.map(point => ({ x: index - 0.4 * point.y / peak, y: point.x })), ...[...points].reverse().map(point => ({ x: index + 0.4 * point.y / peak, y: point.x }))];
polygon.push(polygon[0]);
return { group, n, h, median, halfMedian, peak, points, polygon, index, color: palette[index % palette.length] };
});
return {
type: 'line', data: { datasets: curves.flatMap(curve => [
{ label: curve.group, data: curve.polygon, borderColor: curve.color, backgroundColor: curve.color + '55', fill: 'shape', borderWidth: 1.5, pointRadius: 0, tension: 0, order: 1 },
{ label: 'Median', data: [{ x: curve.index - curve.halfMedian, y: curve.median }, { x: curve.index + curve.halfMedian, y: curve.median }], borderColor: '#0f172a', backgroundColor: '#0f172a', fill: false, borderWidth: 3, pointRadius: 0, tension: 0, order: 0 },
]) },
options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'nearest', intersect: false },
scales: {
x: { type: 'linear', min: -0.5, max: groups.length - 0.5, afterBuildTicks: scale => { scale.ticks = groups.map((_, value) => ({ value })); }, ticks: { maxRotation: 0, callback: value => curves[Number(value)] ? `${curves[Number(value)].group} (n=${curves[Number(value)].n})` : '' }, title: { display: true, text: chartData.observationHeaders[0] }, grid: { display: false } },
y: { title: { display: true, text: chartData.observationHeaders[1] } },
},
plugins: { legend: { display: false }, title: { display: true, text: 'Equal Maximum Width; Dark Mark: Median' }, tooltip: { callbacks: { title: items => items[0] ? curves[Math.floor(items[0].datasetIndex / 2)].group : '', label: item => { const curve = curves[Math.floor(item.datasetIndex / 2)]; return [`Observations: ${curve.n}`, `Median: ${curve.median}`, `Scott Bandwidth: ${curve.h.toPrecision(4)}`]; } } } }, },
};
}
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 reuse the Density Plot data: 12 Email and 12 Chat observations. Email has a median of 8.5 minutes and an observed range of 4 to 18. Chat has a median of 4.5 minutes and a range of 2 to 10. Both shapes have the same maximum width. Their smooth tails extend beyond those observed ranges.
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
| Channel | Minutes |
|---|---|
| 4 | |
| 5 | |
| 6 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 9 | |
| 10 | |
| 12 | |
| 14 | |
| 18 | |
| Chat | 2 |
| Chat | 2 |
| Chat | 3 |
| Chat | 3 |
| Chat | 4 |
| Chat | 4 |
| Chat | 5 |
| Chat | 5 |
| Chat | 6 |
| Chat | 7 |
| Chat | 8 |
| Chat | 10 |
READ THE CHART
Compare Distribution Shapes Beside Their Medians
A violin plot mirrors an estimated density around each group position. The vertical coordinate retains the measurement scale, while wider sections show where that group has a higher estimated concentration. A median mark adds an observed summary to the smoothed shape.
Start With Raw Observations
Each row is a group and an individual value. Repeated measurements retain their weight as separate rows. The groups appear in the order they first occur in the table. Summary quartiles or histogram counts cannot reconstruct these observations. Every group needs at least two different values for the bandwidth used here.
Read Width Within Each Group
Every violin has the same maximum width, regardless of its sample size or peak density. Compare wide and narrow regions within a violin, and compare their positions on the shared vertical scale. Equal widths in different groups need not represent equal densities. The labels include observation counts because width and polygon area do not encode group size.
Separate the Median From the Smoothed Shape
The dark horizontal mark shows the sample median: the middle sorted observation, or the average of the two middle values for an even count. It need not coincide with the widest section. This example does not add quartile boxes, whiskers, or outlier classifications. Keep the raw table available when exact values matter.
Check the Smoothing Assumptions
The examples use equal-weight Gaussian kernels and a separate Scott bandwidth for each group: sample standard deviation times the group count to the power -1/5. Each shape samples 257 values from three bandwidths below its minimum to three above its maximum. Those tails can extend beyond the observed range or below a meaningful zero. They are estimates, not additional observations. Two different values allow calculation but rarely establish a reliable distribution shape.
Compare Consistent Geometry
All six examples calculate the same density grid, scale each sampled peak to the same width, and derive medians from the original observations. They draw the resulting polygons explicitly. The Seaborn tab applies a theme to Matplotlib drawing. Use the Density Plot when comparing absolute density heights matters, or a Box Plot for a more compact summary.