A bar chart race turns a table of numbers-over-time into something you actually watch: horizontal bars that re-sort themselves each period, sliding past one another as the ranking changes. ApexCharts 6.4 makes it a matter of configuration. There is no plugin, no d3, and no hand-written keyframes. You set three options and push each period's ranking on a timer.

Here is one built on real data: the nominal GDP of the world's largest economies, every year from 2000 to 2024, from the World Bank. Watch China climb from sixth to second, and India arrive from outside the top ten.

World's largest economies
Nominal GDP, current US$ · World Bank
2000
Leader in 2000: United States. Data: World Bank, retrieved Jul 2026.

The rest of this post shows you exactly how that chart is built, option by option.

Key takeaways

  • A race is a re-sorting bar chart on a timer. Each frame is one period's ranking; the animation between frames is what makes it a race.
  • ApexCharts 6.4 ships it natively. chart.animations.dynamicAnimation slides the bars, and the new dataLabels.animate and dataLabels.countUp flags make the value labels ride their bars and count. No plugin.
  • You only write the timer. A short updateOptions loop that feeds each period's re-sorted data, categories, and colors is the entire moving part.
  • Colors must follow the ranking, not the position. With distributed bars, ApexCharts colors by bar slot, so you re-order the color array every frame to keep each entity's hue stable.
  • Real data does the storytelling. One value per entity per period is all a race needs; the movement carries the rest, so point it at numbers worth watching.

What makes a bar chart a race?

Three separate motions, and it helps to name them because ApexCharts handles them with different switches:

  1. The bars re-sort. Each frame, the entities are ranked and the bars swap rows. This is the defining motion.
  2. The bars slide. Rather than snapping to their new lengths and rows, they glide there. This is chart.animations.dynamicAnimation.
  3. The labels ride and count. The category label on the left and the value label on the bar travel with the bar to its new row, and the number tweens from its old value to the new one instead of blinking.

The first two ApexCharts has done for years on any data update. The third, labels riding a reorder and counting, is what 6.4 adds, and it is what makes the animation read as a single continuous race rather than a chart that redraws.

Which options turn a bar chart into a race?

Start with an ordinary horizontal bar chart. This is a normal, static ApexCharts config:

const options = {
  chart: { type: 'bar', height: 460 },
  plotOptions: {
    bar: { horizontal: true, distributed: true, borderRadius: 4 },
  },
  series: [{ name: 'GDP', data: frame.data }],
  xaxis: { categories: frame.names },
  colors: frame.colors,
  dataLabels: {
    enabled: true,
    formatter: (v) => `$${v.toFixed(1)}T`,
  },
  legend: { show: false },
}

To make it race, you add exactly three things. Nothing else about the config changes:

const options = {
  chart: {
    type: 'bar',
    height: 460,
    animations: {
      // 1. how fast bars slide to their new rank on each update
      dynamicAnimation: { speed: 900 },
    },
  },
  plotOptions: {
    bar: { horizontal: true, distributed: true, borderRadius: 4 },
  },
  series: [{ name: 'GDP', data: frame.data }],
  xaxis: { categories: frame.names },
  colors: frame.colors,
  dataLabels: {
    enabled: true,
    // 2. value labels ride their bar to the new row (ApexCharts 6.4)
    animate: { enabled: true },
    // 3. and count from the old number to the new one (ApexCharts 6.4)
    countUp: { enabled: true },
    formatter: (v) => `$${v.toFixed(1)}T`,
  },
  legend: { show: false },
}

dataLabels.animate and dataLabels.countUp are off by default and apply to bar and column charts only. Their speed and easing follow dynamicAnimation, so you tune the whole race from one place. That is the entire feature surface. Everything from here is data plumbing.

How do you feed it the data?

A race is a sequence of frames, and each frame is one period's ranking. Your data is a value per entity per period. Ours looks like this, one entry per country with a gdp array indexed by year:

const YEARS = [2000, 2001, 2002, /* ... */ 2024]

const countries = [
  { name: 'United States', flag: '🇺🇸', color: '#3B82F6', gdp: [10.25, 10.58, /* ... */ 29.30] },
  { name: 'China',         flag: '🇨🇳', color: '#EF4444', gdp: [1.22, 1.36, /* ... */ 18.73] },
  { name: 'India',         flag: '🇮🇳', color: '#8B5CF6', gdp: [0.47, 0.49, /* ... */ 3.76] },
  // ... the rest
]

For each year, rank the countries and keep the top ten. This function returns everything a frame needs:

function frameFor(yearIndex) {
  const ranked = countries
    .map((c) => ({ ...c, value: c.gdp[yearIndex] }))
    .sort((a, b) => b.value - a.value)
    .slice(0, 10)

  return {
    names: ranked.map((c) => c.name),
    data: ranked.map((c) => c.value),
    // colors ride the ranking too, see the note below
    colors: ranked.map((c) => c.color),
  }
}

Now the race itself. Render the first frame, then step forward on a timer, handing each new frame to updateOptions:

const chart = new ApexCharts(el, { ...options, ...withFrame(frameFor(0)) })
chart.render()

let year = 0
setInterval(() => {
  year = (year + 1) % YEARS.length
  const frame = frameFor(year)
  chart.updateOptions({
    series: [{ data: frame.data }],
    xaxis: { categories: frame.names },
    colors: frame.colors,
  })
}, 1100)

Keep the interval a little longer than dynamicAnimation.speed (here 1100ms against 900ms) so each slide finishes before the next begins. That is the whole race: a render, then a loop that swaps in re-sorted data.

Why re-order the colors every frame?

This is the one subtlety that trips people up. With plotOptions.bar.distributed: true, ApexCharts assigns colors from the colors array by bar position, not by entity. The top bar always gets colors[0]. In a race the top bar is a different country every few frames, so if you leave colors fixed, the colors stay put while the countries slide through them, and the whole thing strobes.

The fix is in frameFor above: build the colors array from the same ranked list as the data, so colors[0] is always the current leader's own color. Each country then keeps its hue as it moves up and down the board. Do the same for any per-entity decoration, like the flag emoji on the axis label:

const flagByName = Object.fromEntries(countries.map((c) => [c.name, c.flag]))

yaxis: {
  labels: {
    formatter: (name) => `${flagByName[name] || ''}  ${name}`,
  },
},

SVG axis labels are plain text, so an emoji is the simplest way to put an icon in front of a label. Real image flags would need custom DOM work.

The same pattern works for any race: swap the loader and the chart code stays the same, whether the values come from an economic dataset, npm's downloads API, GitHub stars, or your own product analytics.

How do you keep the animation respectful?

Motion is persuasive, which is exactly why it needs guardrails. The live chart above follows four rules worth copying:

  • Never autoplay under reduced motion. Check window.matchMedia('(prefers-reduced-motion: reduce)').matches and start paused if it is set. Offer a play button so the choice stays with the reader.
  • Give explicit play, pause, and replay controls. A race that cannot be stopped is a race that fights the reader trying to read a tooltip.
  • Pause when off-screen. An IntersectionObserver that stops the timer when the chart scrolls out of view saves work and stops a chart animating to an empty room.
  • Pair it with a static view. Offer the same ranking as a plain table or sorted bar chart nearby, so the information is available to anyone who will not or cannot watch the animation through.

And know when not to use one. A race is built for a single message: this ranking changed, and here is roughly how. It is poor at precise comparison, because a value that is only on screen for a second cannot be read carefully, and it hides everything outside the visible top ten. If your reader needs to compare exact figures or study the long tail, a small-multiples grid or a plain sorted bar chart serves them better. Use the race for the overview, then let a static chart carry the detail.

Summary

A bar chart race is a re-sorting horizontal bar chart on a timer, and in ApexCharts 6.4 the animation is entirely declarative: dynamicAnimation slides the bars, and dataLabels.animate and dataLabels.countUp make the labels ride and count. The only code you own is a short loop that feeds each period's ranking through updateOptions, plus the one habit of re-ordering the colors array so each entity keeps its hue. Point it at real data, respect reduced motion, and give clear playback controls. Do that, and the feature stops being a novelty and becomes a genuinely good way to show how a ranking changed, whether that is the world's economies over a quarter century or your own product's metrics over a quarter.

Frequently asked questions

What is a bar chart race?

A bar chart race is an animated horizontal bar chart where the bars re-sort themselves as an underlying value changes over time. Each frame is one period (a year, a month), the bars slide to their new ranking, and the value labels count up or down. It is used to show how a ranking evolved, for example which country, company, or product led in each year.

How do you make a bar chart race in ApexCharts?

In ApexCharts 6.4, set chart.animations.dynamicAnimation to control the slide speed, and turn on dataLabels.animate and dataLabels.countUp so the labels ride their bars and tween their numbers. Then call chart.updateOptions on a timer, passing each period's re-sorted data and categories. The reordering animation is built in; you only supply the sequence of rankings.

Do I need a plugin or a separate library for a bar chart race?

No. ApexCharts 6.4 handles the race natively for bar and column charts. You do not need d3, a race-specific library, or hand-written keyframes. The only code you write is the timer that pushes each period's ranking into the chart with updateOptions.

How much data does a bar chart race need?

One numeric value per entity per period, plus a label per entity. For the GDP race here that is one GDP figure per country per year. Keep the visible bars to about 8 to 12; more than that and the viewer cannot track any single bar as it moves.

Is a bar chart race accessible?

Motion charts need care. Do not autoplay under prefers-reduced-motion, offer explicit play and pause controls, and pair the animation with a static table of the final (or key) frames so the information is available without watching. A race is good for engagement and for showing that a ranking changed; a static chart is better when the reader needs to compare exact values.

Where can I get data to build a bar chart race?

Any source with a value per entity over time works: the World Bank and IMF for economic data, npm or GitHub APIs for software metrics, sports and elections records, or your own product analytics. This tutorial pulls nominal GDP from the World Bank's open data API (indicator NY.GDP.MKTP.CD), which is free to query and returns a clean value per country per year.