One word makes the plot move

R
Python
Julia
JavaScript
rstats
julialang
Hans Rosling’s bubble chart is a scatterplot with one word added. In gog, time is a channel, written beside color and size, so animation is not a second package with a second vocabulary. The same word moves a bar chart, a histogram and a rotating cube.
Author

psychometrician

Published

August 24, 2026

You have seen this chart. A cloud of bubbles, one per country, income along the bottom and life expectancy up the side. It plays through half a century, and the whole cloud climbs and moves right as the world gets richer and lives longer.

Hans Rosling showed it to a lecture hall in 2006 and then to about fifteen million people. It is probably the best known moving chart there is.

So how do you make one?

The table, and how to get it

Everything below reads one table: gm_all, the gapminder data, 142 countries recorded every five years from 1952 to 2007.

You do not have to download it. It is published beside gog’s manual, and every one of the four packages can fetch it by name:

gm <- gog_table("gm_all")
gm = gog_table("gm_all")
gm = gog_table("gm_all")
const gm = await gog_table("gm_all");

That is the same call in all four, and it needs no file, no reader and no argument beyond the name. There are 39 tables published this way, so every plot in this post is one you can run in the next minute.

gm: first 5 of 1704 rows
country continent year life population gdp
Afghanistan Asia 1952 28.801 8425333 779.4453
Afghanistan Asia 1957 30.332 9240934 820.8530
Afghanistan Asia 1962 31.997 10267083 853.1007
Afghanistan Asia 1967 34.020 11537966 836.1971
Afghanistan Asia 1972 36.088 13079460 739.9811

Six columns. year is the one this post is about.

The plot, still

Start with one year and no motion. This is 2007 on its own, income against life expectancy, each bubble sized by population and colored by continent:

gm_2007 <- gm[gm$year == 2007, ]
data(gm_2007) + point + x(gdp, scale = "log") + y(life) + size(population) +
  color(continent)
1K 10K 40 50 60 70 80 Life Gdp Continent Asia Europe Africa Americas Oceania Population 199.6K 659.4M 1.3B

Read it aloud: given gapminder 2007, points, x is gdp on a log scale, y is life, size by population, color by continent.

That is Rosling’s chart with the time taken out of it.

The plot, moving

Put the time back by naming the column that holds it:

data(gm) + point + x(gdp, scale = "log") + y(life) + size(population) +
  color(continent) + play(year)
(data(gm) + point + x(col.gdp, scale = "log") + y(col.life) + size(col.population) +
  color(col.continent) + play(col.year))
data(gm) + point + x(:gdp, scale = "log") + y(:life) + size(:population) +
  color(:continent) + play(:year)
plot(data(gm), point, x(col.gdp, { scale: "log" }), y(col.life),
  size(col.population), color(col.continent), play(col.year))
1K 10K 100K 40 60 80 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Life Gdp Continent Asia Europe Africa Americas Oceania Population 60K 659.4M 1.3B

Twelve frames, 1952 to 2007, and the strip above the panel names the one you are looking at.

You do not have to watch it go past. A plot that plays carries three boxes a still one does not: pause in the middle, and a step to either side of it. Stepping pauses as well, so you can hold one year still and read it. Rest on any of the three and it says what it does.

Compare the two sentences. The table is now every year instead of one, and play(year) has been added at the end. Nothing else moved. No renderer was chosen, no frame rate was set, no second function wrapped the plot, and no file was written out and read back.

What play actually is

It is a channel, and that is the whole of the design.

gog has ten channels. x and y and z are positions. color, size, shape, pattern and opacity are appearances. group splits a series. And play is time. They are written the same way, they combine with everything, and none of them is special.

So the shortest true description of play is that it is faceting read in time. A facet splits the rows by a column’s values and lays the pieces across the page. play splits them exactly the same way and lays them out one after another.

Here are four years as panels:

gm_four <- gm[gm$year %in% c(1952, 1972, 1992, 2007), ]
gm_four$period <- factor(gm_four$year)
data(gm_four) + point + x(gdp, scale = "log") + y(life) + color(continent) |
  facet(period)
1K 10K 100K 40 60 80 1K 10K 100K 1K 10K 100K 1K 10K 100K 1952 1972 1992 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

And the same four years as frames:

data(gm_four) + point + x(gdp, scale = "log") + y(life) + color(continent) +
  play(period)
(data(gm_four) + point + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) +
  play(col.period))
data(gm_four) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + play(:period)
plot(data(gm_four), point, x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), play(col.period))
1K 10K 100K 40 60 80 1952 1972 1992 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

Identical rows, one operator different. The grid is better for comparing two moments carefully, because both are in front of you and your eye can go back and forth. The sequence is better for seeing movement, because a bubble that shifts left is one you watch move rather than one you have to find twice.

Neither is a different plot. They are one plot read two ways.

Why the frames can be trusted

Every scale, the color map and every legend are fitted across the whole sequence at once, never per frame.

That sounds like an implementation detail. It is the property that makes the whole thing worth watching, and the easiest way to see why is to imagine its absence. If each frame fitted its own axis, the poorest countries of 1952 and the poorest of 2007 would both sit against the left edge. A world whose incomes tripled would draw as a world standing still. The only thing actually moving would be the axis, silently, with nothing on the page to say so.

Because the frames share their scales, the opposite reads correctly. The cloud genuinely moves right and up, and a country that does not move is a country that did not.

The colors are shared for a second reason. You follow a bubble by its color, so a palette that changed partway through would take away the one thing you are following. Every continent keeps its color in every frame, including frames where it has no rows at all.

The same word on other plots

play is a channel, so it goes wherever a channel goes. Nothing below is a new feature, and none of it needed a line of code added to the library.

A bar chart counting the countries that had passed sixty years of life expectancy:

gm_past60 <- gm[gm$life > 60, ]
data(gm_past60) + bar * count + x(continent) + play(year)
Americas Oceania Europe Asia Africa 0 10 20 30 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Count Continent

Asia climbs from four to thirty. Africa opens with none and ends with twelve. Europe starts at twenty-three and then runs out of countries to add.

A histogram of life expectancy, walking right across half a century:

data(gm) + bar * bin + x(life) + play(year)
data(gm) + bar * bin + x(col.life) + play(col.year)
data(gm) + bar * bin + x(:life) + play(:year)
plot(data(gm), layer(bar, bin), x(col.life), play(col.year))
40 60 80 0 10 20 30 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Count Life

The bars keep one width through the whole sequence, which is the only way that walk is legible. One set of intervals is cut from every frame’s rows at once, and each frame then counts only its own rows into them.

And a cube, which you can turn while it plays:

data(gm) + point + x(gdp, scale = "log") + y(life) + z(population) +
  color(continent) + play(year)
(data(gm) + point + x(col.gdp, scale = "log") + y(col.life) + z(col.population) +
  color(col.continent) + play(col.year))
data(gm) + point + x(:gdp, scale = "log") + y(:life) + z(:population) +
  color(:continent) + play(:year)
plot(data(gm), point, x(col.gdp, { scale: "log" }), y(col.life),
  z(col.population), color(col.continent), play(col.year))
100K 10K 1K 80 60 40 1000M 500M 0M Gdp Life Population 1952 1957 1962 1967 1972 1977 1982 1987 1992 1997 2002 2007 Continent Asia Europe Africa Americas Oceania

Drag it to another angle. The angle survives the frames, so you can find a view and then watch the years pass from it.

Four plots, four kinds of picture, and play(year) written the same way in all of them. That is what a channel buys. The word you learn on a scatterplot is the word that works on the histogram and in the cube, and there is no second thing to read on the way.

When a plot should not move

This is the part a feature tour leaves out, and it is the more useful half.

Animation is the choice most people want to make, and it is often the wrong one. A facet costs a reader one glance. A sequence asks them to watch, and keeps them for as long as the loop runs. If it then says nothing, that time was spent for nothing, and a reader can usually tell.

A short sequence is worth watching only when its frames hold the same things in different states. Then you can follow one of them across the frames. The four years above are that case: the same countries at four dates, and the cloud climbs. Four frames holding four different sets of things are not that case. Nothing moves between them, and the reader waits to be shown what a grid would have shown at once.

There is also a column that looks playable and is not. A facet lays its panels out in space, and you read space in any order you like. A sequence lays its pieces out in time, and time arrives in one order only. So a sequence claims something a grid never claims: this frame comes after that one.

Bind a column with no order, and the plot shows movement the data does not have:

data(gm_2007) + point + x(gdp, scale = "log") + y(life) + color(continent) +
  play(continent)
(data(gm_2007) + point + x(col.gdp, scale = "log") + y(col.life) + color(col.continent) +
  play(col.continent))
data(gm_2007) + point + x(:gdp, scale = "log") + y(:life) +
  color(:continent) + play(:continent)
plot(data(gm_2007), point, x(col.gdp, { scale: "log" }), y(col.life),
  color(col.continent), play(col.continent))
gog: `play(continent)` has no stated order, so each frame is a snapshot of one `continent` and the frames run in the order the rows arrive: Asia, Europe, Africa, Americas, Oceania. A sequence claims that one frame comes after another. Set the column's factor levels where the data lives, or use `facet(continent)`, which claims no order.
1K 10K 40 50 60 70 80 Asia Europe Africa Americas Oceania Life Gdp Continent Asia Europe Africa Americas Oceania

Watch the strip. Asia, Europe, Africa, Americas, Oceania. That order is not alphabetical and it is not geographic. It is the order the continents first appear in the table, because the rows are sorted by country and Afghanistan comes before Albania. The sequence is showing you how a file was sorted, and none of the jumps mean anything.

gog says so above the plot, and then draws it anyway. Nothing about the sentence is malformed, so the sequence is yours to keep if you meant it. The message names the order it had to choose and the two answers to it: declare the column’s levels, or facet instead.

That is the rule the whole project runs on. A plot is refused for not being well formed, never for being a poor idea. Which picture says more about your data is your judgment, and the engine’s job is to tell you what it had to assume.

Taking the sequence with you

A page can play. A slide deck and a journal cannot, so the same sentence writes a file:

save_gif(data(gm) + point + x(gdp, scale = "log") + y(life) +
           size(population) + color(continent) + play(year),
         "rosling.gif")

Same sentence, wrapped in one call, and the frames it wrote are the frames you watched.

What this is not

gog is version 0.1.0, and that number is accurate. It is young. There are things mature libraries draw that it cannot, and the manual keeps a dated list of exactly what those are, in both directions.

In R, the established answer to this problem is gganimate, and it is good work. It gives ggplot2 a family of transitions, easing between frames, and shadows that leave a trail behind a moving point. gog has none of those yet. What it has instead is one arrangement. The word for time sits in the same list as the word for color, so there is no second package and no second vocabulary.

What you can do

Run the plots above

Every one reads gog_table("gm_all"), so there is nothing to download. Three of the four packages carry the drawing engine inside them, built for your platform.

# R
install.packages("gog", repos = c("https://psychometrician.r-universe.dev",
                                  "https://cloud.r-project.org"))
# Python
pip install gog
# Julia
using Pkg; Pkg.add("GrammarOfGraphics")
# JavaScript
npm install grammar-of-graphics

Read the chapter

The manual has 58 chapters, and every plot in them is drawn by the engine as the page builds. The Play chapter covers speed, a layer that stands still while another moves, and the sentences play refuses.

Tell us where it is wrong

Draw data you know well here, and draw the same thing in a tool you already trust. Put the two pictures side by side and say where they differ. That is the most useful thing anyone can do for this project, because the checks compare the four packages against each other and cannot prove that all four are right.

Issues are open. The source is on GitHub, Apache 2.0.

Be agog. Use gog.