Fifty bubbles with made-up values wander across four colored quadrants as the years run from 1953 to 2025. Press play to watch them move and grow, or drag the slider to any year.
Animation adds time to a bubble chart, and quadrants give the movement a meaning: crossing a line means moving into another group, like from low growth to high. It suits a story told in a talk or on a page. To compare two years exactly, show them side by side instead.
Built with amCharts 5, a JavaScript library. The JavaScript includes your theme and renderer (canvas or SVG); the JSON also follows the changes you make with the controls.
npm install @amcharts/amcharts5. See Getting started.
The same chart as a JSON config. Chart Maker reads and writes this format, the library's JSON parser turns it into a chart, and the <am5-chart> element puts it on a page with no code."type" is the class to create and "settings" what you would pass to its new() in code; "properties" fill in its parts, lists such as series and axes or templates such as columns.template; "refs" holds objects used in more than one place ("#series-0" stands for the one named series-0); a string starting with "@" takes a value from another object, such as "@series.get('fill')".
A complete page. Save it as an .html file and open it in a browser.
Paste this into an AI coding assistant to build the chart from scratch. More in amCharts & AI.
// Create root element
// https://www.amcharts.com/docs/v5/getting-started/#Root_element
var root = am5.Root.new("chartdiv");
// Set themes
// https://www.amcharts.com/docs/v5/concepts/themes/
root.setThemes([
am5themes_Animated.new(root),
am5themes_Responsive.new(root)
]);
var yearData = {};
var firstYear = 1953;
var lastYear = 2025;
var currentYear = firstYear; // the year on show
// random data: 50 bubbles a year, each moving a little from where it was the year before
for (var year = firstYear; year <= lastYear; year++) {
var data = [];
yearData[year] = data;
for (var i = 0; i < 50; i++) {
if (year == firstYear) {
data.push({
x: Math.round(Math.random() * 100 - 90), // a random start between -90 and 10
y: Math.round(Math.random() * 100 - 90),
value: Math.round(Math.random() * 1000)
});
} else {
var previous = yearData[year - 1][i];
data.push({
// a small random step, drifting up and to the right
x: previous.x + Math.round(Math.random() * 5 - 2 + i / 50),
y: previous.y + Math.round(Math.random() * 5 - 2 + i / 50),
// the size changes a little too, never below zero
value: Math.abs(previous.value + Math.round(Math.random() * 100 - 45))
});
}
}
}
// Stack the chart and the play controls under it
root.container.set("layout", root.verticalLayout);
// Create chart
// https://www.amcharts.com/docs/v5/charts/xy-chart/
var chart = root.container.children.push(am5xy.XYChart.new(root, {
panX: true, // drag the plot to pan sideways...
panY: true, // ...and up and down
wheelY: "zoomXY", // the mouse wheel zooms in on both axes
pinchZoomX:true, // pinch to zoom on touch screens, both ways
pinchZoomY:true
}));
// Create axes
// https://www.amcharts.com/docs/v5/charts/xy-chart/axes/
var xAxis = chart.xAxes.push(am5xy.ValueAxis.new(root, {
min: -100, // the axis runs from -100...
max: 100, // ...to 100
renderer: am5xy.AxisRendererX.new(root, { minGridDistance: 50 }), // at least 50px between labels
tooltip: am5.Tooltip.new(root, {}) // the cursor shows its x value on the axis
}));
// Skip the first label, which would run into the Y axis label in the corner,
// and the last one, which the vertical scrollbar would cover
xAxis.get("renderer").labels.template.setAll({
minPosition: 0.02,
maxPosition: 0.98
});
var yAxis = chart.yAxes.push(am5xy.ValueAxis.new(root, {
min: -100, // the same -100 to 100 range
max: 100,
renderer: am5xy.AxisRendererY.new(root, {}),
tooltip: am5.Tooltip.new(root, {}) // the cursor shows its y value on the axis
}));
// color fills
// each quadrant is a filled square drawn by a line series without a line, in its own color from the theme
// (every third color of the chart's color set, so neighboring quadrants differ clearly)
chart.get("colors").set("step", 3);
var series0 = chart.series.push(am5xy.LineSeries.new(root, {
xAxis: xAxis,
yAxis: yAxis,
valueXField: "ax",
valueYField: "ay"
}));
series0.fills.template.setAll({ fillOpacity: 0.9, visible: true }); // show the fill, nearly opaque
series0.strokes.template.set("forceHidden", true); // no outline
series0.data.setAll([ // the upper left quadrant, reaching past the axis ends
{ ax: -200, ay: 0 },
{ ax: 0, ay: 0 },
{ ax: 0, ay: 200 },
{ ax: -200, ay: 200 }
]);
var series1 = chart.series.push(am5xy.LineSeries.new(root, {
xAxis: xAxis,
yAxis: yAxis,
valueXField: "ax",
valueYField: "ay"
}));
series1.fills.template.setAll({ fillOpacity: 0.9, visible: true });
series1.strokes.template.set("forceHidden", true);
series1.data.setAll([ // the lower left quadrant
{ ax: -200, ay: 0 },
{ ax: 0, ay: 0 },
{ ax: 0, ay: -200 },
{ ax: -200, ay: -200 }
]);
var series2 = chart.series.push(am5xy.LineSeries.new(root, {
xAxis: xAxis,
yAxis: yAxis,
valueXField: "ax",
valueYField: "ay"
}));
series2.fills.template.setAll({ fillOpacity: 0.9, visible: true });
series2.strokes.template.set("forceHidden", true);
series2.data.setAll([ // the lower right quadrant
{ ax: 200, ay: 0 },
{ ax: 0, ay: 0 },
{ ax: 0, ay: -200 },
{ ax: 200, ay: -200 }
]);
var series3 = chart.series.push(am5xy.LineSeries.new(root, {
xAxis: xAxis,
yAxis: yAxis,
valueXField: "ax",
valueYField: "ay"
}));
series3.fills.template.setAll({ fillOpacity: 0.9, visible: true });
series3.strokes.template.set("forceHidden", true);
series3.data.setAll([ // the upper right quadrant
{ ax: 200, ay: 0 },
{ ax: 0, ay: 0 },
{ ax: 0, ay: 200 },
{ ax: 200, ay: 200 }
]);
// The grid lines on a layer of their own, drawn over the color fills
xAxis.get("renderer").grid.template.set("layer", 1);
yAxis.get("renderer").grid.template.set("layer", 1);
// Create series
// https://www.amcharts.com/docs/v5/charts/xy-chart/series/
var series = chart.series.push(am5xy.LineSeries.new(root, {
calculateAggregates: true, // finds the lowest and highest value, for the heat rule below
xAxis: xAxis,
yAxis: yAxis,
valueYField: "y",
valueXField: "x",
valueField: "value"
}));
series.strokes.template.set("visible", false); // no line between the points, only the bubbles
// Add bullet
// https://www.amcharts.com/docs/v5/charts/xy-chart/series/#Bullets
var circleTemplate = am5.Template.new({}); // one template for all circles, so the heat rule can size them
series.bullets.push(function () {
var bulletCircle = am5.Circle.new(root, {
radius: 5, // the heat rule replaces this
fill: root.interfaceColors.get("alternativeBackground"), // the theme's text color: dark on light, light on dark
fillOpacity: 0.6, // see-through, so overlaps show
// values are rounded: while the years play, they are in between two years' data
tooltipText: "x: {valueX.formatNumber('#.')}, y: {valueY.formatNumber('#.')}, value: {value.formatNumber('#,###.')}"
}, circleTemplate);
return am5.Bullet.new(root, {
sprite: bulletCircle
});
});
// Add heat rule
// https://www.amcharts.com/docs/v5/concepts/settings/heat-rules/
// a fixed maxValue, so the sizes don't scale to each year's largest value
series.set("heatRules", [{
target: circleTemplate,
min: 3, // the smallest bubble has a 3px radius...
max: 35, // ...the biggest 35px
dataField: "value",
key: "radius", maxValue:2000 // sets the radius; a value of 2000 gets the biggest
}]);
// Add cursor
// https://www.amcharts.com/docs/v5/charts/xy-chart/cursor/
chart.set("cursor", am5xy.XYCursor.new(root, {
xAxis: xAxis,
yAxis: yAxis
}));
// Add scrollbars
// https://www.amcharts.com/docs/v5/charts/xy-chart/scrollbars/
chart.set("scrollbarX", am5.Scrollbar.new(root, {
orientation: "horizontal" // zoom and pan along x...
}));
chart.set("scrollbarY", am5.Scrollbar.new(root, {
orientation: "vertical" // ...and along y
}));
// Label
var label = chart.plotContainer.children.push(am5.Label.new(root, {
text: currentYear.toString(), // the year on show
fontSize: "5em", // five times the chart's text size
fill: root.interfaceColors.get("alternativeBackground"), // the theme's text color
opacity: 0.3 // faint, so the bubbles show through
}));
// Create controls: a play button and a year slider under the chart, clear of the bubbles
var container = root.container.children.push(am5.Container.new(root, {
width: am5.percent(100), // the full width under the chart
layout: root.horizontalLayout, // the button and the slider side by side
paddingTop: 10, // space between the chart and the controls
paddingLeft: 40, // room at both ends
paddingRight: 40,
// leave the controls out of exported images
exportable: false
}));
var playButton = container.children.push(am5.Button.new(root, {
themeTags: ["play"], // the theme makes it a play button that turns to pause when active
centerY: am5.p50, // lined up with the slider
marginRight: 20, // space before the slider
icon: am5.Graphics.new(root, {
themeTags: ["icon"] // the play and pause icon from the theme
})
}));
// play from where the slider is, or pause
playButton.events.on("click", function () {
if (playButton.get("active")) {
// pause: setting start directly stops its running animation
slider.set("start", slider.get("start") + 0.0001);
} else {
slider.animate({
key: "start",
to: 1, // move the grip to the end...
duration: 15000 * (1 - slider.get("start")) // ...in 15 seconds for the whole timeline, less from part-way
});
}
});
var slider = container.children.push(am5.Slider.new(root, {
orientation: "horizontal",
start: 0, // the grip starts at the first year
centerY: am5.p50 // lined up with the button
}));
// at the end, the button goes back to play
slider.on("start", function (start) {
if (start === 1) {
playButton.set("active", false);
}
});
// show the year the grip points at
slider.events.on("rangechanged", function () {
updateSeriesData(
firstYear + Math.round(slider.get("start", 0) * (lastYear - firstYear))
);
});
// swap in a year's data point by point, so each bubble moves to its new spot
function updateSeriesData(year) {
if (currentYear != year) {
currentYear = year;
var data = yearData[year];
var i = 0;
am5.array.each(data, function (item) {
series.data.setIndex(i, item);
i++;
});
label.set("text", year.toString());
}
}
series.data.setAll(yearData[currentYear]);
// Make stuff animate on load
// https://www.amcharts.com/docs/v5/concepts/animations/
series.appear(1000);
chart.appear(1000, 100);
{
"type": "XYChart",
"settings": {
"panX": true,
"panY": true,
"wheelY": "zoomXY",
"pinchZoomX": true,
"pinchZoomY": true,
"cursor": {
"type": "XYCursor",
"settings": {
"xAxis": "#axis-0",
"yAxis": "#axis-1"
}
},
"scrollbarX": {
"type": "Scrollbar",
"settings": {
"orientation": "horizontal"
}
},
"scrollbarY": {
"type": "Scrollbar",
"settings": {
"orientation": "vertical"
}
},
"colors": {
"type": "ColorSet",
"settings": {
"step": 3,
"colors": [
"@root.interfaceColors.get('primaryButton')",
{
"type": "Color",
"value": "#7a51a5"
},
{
"type": "Color",
"value": "#a95596"
},
{
"type": "Color",
"value": "#ce5f78"
},
{
"type": "Color",
"value": "#e3764f"
},
"@root.interfaceColors.get('primaryButton')",
{
"type": "Color",
"value": "#7a51a5"
},
{
"type": "Color",
"value": "#a95596"
},
{
"type": "Color",
"value": "#ce5f78"
},
{
"type": "Color",
"value": "#e3764f"
},
"@root.interfaceColors.get('primaryButton')",
{
"type": "Color",
"value": "#7a51a5"
},
{
"type": "Color",
"value": "#a95596"
},
{
"type": "Color",
"value": "#ce5f78"
},
{
"type": "Color",
"value": "#e3764f"
}
],
"reuse": true,
"passOptions": {
"hue": 0.05,
"saturation": 0,
"lightness": 0
},
"startIndex": 0,
"stateAnimationDuration": 600
}
}
},
"properties": {
"series": [
"#series-0",
"#series-1",
"#series-2",
"#series-3",
"#series-4"
],
"xAxes": [
"#axis-0"
],
"yAxes": [
"#axis-1"
],
"plotContainer": {
"children": [
{
"type": "Label",
"settings": {
"text": "1953",
"fontSize": "5em",
"fill": "@root.interfaceColors.get('alternativeBackground')",
"opacity": 0.3
},
"index": 5
}
]
}
},
"refs": [
{
"data-0": [
{
"ax": -200,
"ay": 0
},
{
"ax": 0,
"ay": 0
},
{
"ax": 0,
"ay": 200
},
{
"ax": -200,
"ay": 200
}
]
},
{
"data-1": [
{
"ax": -200,
"ay": 0
},
{
"ax": 0,
"ay": 0
},
{
"ax": 0,
"ay": -200
},
{
"ax": -200,
"ay": -200
}
]
},
{
"data-2": [
{
"ax": 200,
"ay": 0
},
{
"ax": 0,
"ay": 0
},
{
"ax": 0,
"ay": -200
},
{
"ax": 200,
"ay": -200
}
]
},
{
"data-3": [
{
"ax": 200,
"ay": 0
},
{
"ax": 0,
"ay": 0
},
{
"ax": 0,
"ay": 200
},
{
"ax": 200,
"ay": 200
}
]
},
{
"data-4": [
{
"x": -2,
"y": -38,
"value": 276
},
{
"x": -6,
"y": -40,
"value": 837
},
{
"x": 3,
"y": -64,
"value": 305
},
{
"x": -9,
"y": -51,
"value": 771
},
{
"x": -27,
"y": -82,
"value": 521
},
{
"x": -14,
"y": -55,
"value": 876
},
{
"x": -9,
"y": -84,
"value": 174
},
{
"x": 0,
"y": -31,
"value": 43
},
{
"x": -83,
"y": -88,
"value": 742
},
{
"x": 1,
"y": -43,
"value": 696
},
{
"x": -74,
"y": -12,
"value": 553
},
{
"x": -50,
"y": -12,
"value": 979
},
{
"x": -63,
"y": -6,
"value": 233
},
{
"x": -88,
"y": -45,
"value": 508
},
{
"x": -24,
"y": -78,
"value": 964
},
{
"x": 8,
"y": -7,
"value": 849
},
{
"x": -70,
"y": -67,
"value": 900
},
{
"x": -33,
"y": -44,
"value": 880
},
{
"x": -56,
"y": -65,
"value": 595
},
{
"x": -68,
"y": -3,
"value": 110
},
{
"x": -39,
"y": -27,
"value": 114
},
{
"x": -16,
"y": 0,
"value": 421
},
{
"x": -51,
"y": -47,
"value": 110
},
{
"x": -70,
"y": -2,
"value": 762
},
{
"x": -70,
"y": -80,
"value": 765
},
{
"x": -89,
"y": -80,
"value": 966
},
{
"x": -64,
"y": -23,
"value": 348
},
{
"x": 6,
"y": -48,
"value": 631
},
{
"x": -17,
"y": -85,
"value": 386
},
{
"x": -26,
"y": -18,
"value": 10
},
{
"x": -85,
"y": -44,
"value": 378
},
{
"x": -59,
"y": -19,
"value": 546
},
{
"x": -74,
"y": -58,
"value": 19
},
{
"x": -34,
"y": -67,
"value": 737
},
{
"x": 7,
"y": -68,
"value": 216
},
{
"x": -60,
"y": -37,
"value": 289
},
{
"x": -75,
"y": -64,
"value": 357
},
{
"x": -24,
"y": -55,
"value": 450
},
{
"x": -66,
"y": -40,
"value": 117
},
{
"x": -9,
"y": -37,
"value": 380
},
{
"x": 6,
"y": -82,
"value": 160
},
{
"x": -32,
"y": -20,
"value": 792
},
{
"x": -42,
"y": 0,
"value": 955
},
{
"x": -31,
"y": -89,
"value": 660
},
{
"x": -4,
"y": -56,
"value": 956
},
{
"x": -28,
"y": -73,
"value": 584
},
{
"x": -2,
"y": -81,
"value": 299
},
{
"x": 8,
"y": -86,
"value": 766
},
{
"x": -27,
"y": -51,
"value": 655
},
{
"x": 6,
"y": -80,
"value": 846
}
]
},
{
"template-0": {
"type": "Template"
}
},
{
"axis-0": {
"type": "ValueAxis",
"settings": {
"min": -100,
"max": 100,
"renderer": {
"type": "AxisRendererX",
"settings": {
"minGridDistance": 50
},
"properties": {
"labels": {
"properties": {
"template": {
"settings": {
"minPosition": 0.02,
"maxPosition": 0.98
}
}
}
},
"grid": {
"properties": {
"template": {
"settings": {
"layer": 1
}
}
}
}
}
},
"tooltip": {
"type": "Tooltip"
}
}
}
},
{
"axis-1": {
"type": "ValueAxis",
"settings": {
"min": -100,
"max": 100,
"renderer": {
"type": "AxisRendererY",
"properties": {
"grid": {
"properties": {
"template": {
"settings": {
"layer": 1
}
}
}
}
}
},
"tooltip": {
"type": "Tooltip"
}
}
}
},
{
"series-0": {
"type": "LineSeries",
"settings": {
"xAxis": "#axis-0",
"yAxis": "#axis-1",
"valueXField": "ax",
"valueYField": "ay"
},
"properties": {
"data": "#data-0",
"strokes": {
"properties": {
"template": {
"settings": {
"forceHidden": true
}
}
}
},
"fills": {
"properties": {
"template": {
"settings": {
"fillOpacity": 0.9,
"visible": true
}
}
}
}
}
}
},
{
"series-1": {
"type": "LineSeries",
"settings": {
"xAxis": "#axis-0",
"yAxis": "#axis-1",
"valueXField": "ax",
"valueYField": "ay"
},
"properties": {
"data": "#data-1",
"strokes": {
"properties": {
"template": {
"settings": {
"forceHidden": true
}
}
}
},
"fills": {
"properties": {
"template": {
"settings": {
"fillOpacity": 0.9,
"visible": true
}
}
}
}
}
}
},
{
"series-2": {
"type": "LineSeries",
"settings": {
"xAxis": "#axis-0",
"yAxis": "#axis-1",
"valueXField": "ax",
"valueYField": "ay"
},
"properties": {
"data": "#data-2",
"strokes": {
"properties": {
"template": {
"settings": {
"forceHidden": true
}
}
}
},
"fills": {
"properties": {
"template": {
"settings": {
"fillOpacity": 0.9,
"visible": true
}
}
}
}
}
}
},
{
"series-3": {
"type": "LineSeries",
"settings": {
"xAxis": "#axis-0",
"yAxis": "#axis-1",
"valueXField": "ax",
"valueYField": "ay"
},
"properties": {
"data": "#data-3",
"strokes": {
"properties": {
"template": {
"settings": {
"forceHidden": true
}
}
}
},
"fills": {
"properties": {
"template": {
"settings": {
"fillOpacity": 0.9,
"visible": true
}
}
}
}
}
}
},
{
"series-4": {
"type": "LineSeries",
"settings": {
"calculateAggregates": true,
"xAxis": "#axis-0",
"yAxis": "#axis-1",
"valueYField": "y",
"valueXField": "x",
"valueField": "value",
"heatRules": [
{
"target": "#template-0",
"min": 3,
"max": 35,
"dataField": "value",
"key": "radius",
"maxValue": 2000,
"__parse": true
}
]
},
"properties": {
"data": "#data-4",
"strokes": {
"properties": {
"template": {
"settings": {
"visible": false
}
}
}
},
"bullets": [
{
"type": "Bullet",
"settings": {
"sprite": {
"type": "Circle",
"settings": {
"radius": 5,
"fill": "@root.interfaceColors.get('alternativeBackground')",
"fillOpacity": 0.6,
"tooltipText": "x: {valueX.formatNumber('#.')}, y: {valueY.formatNumber('#.')}, value: {value.formatNumber('#,###.')}"
},
"properties": {
"template": "#template-0"
}
}
}
}
]
}
}
}
]
}