> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Bar Chart Race

> Ranked bars grow and overtake each other as a time series advances, with the axis rescaling as values climb and the accent handing over to whoever leads

# Bar Chart Race

Ranked bars grow and overtake each other as a time series advances, with the axis rescaling as values climb and the accent handing over to whoever leads

**Bar Chart Race**

```html bar-chart-race.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Bar Chart Race</title>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background-color: #f5f3ef;
      }
    </style>
  </head>
  <body>
    <div
      id="bcr-root"
      data-composition-id="bar-chart-race"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="12"
      data-composition-variables='[
        {"id":"title","type":"string","label":"Title","default":"Streaming Subscribers by Service"},
        {"id":"subtitle","type":"string","label":"Subtitle","default":"Ranked by reported subscribers"},
        {"id":"periods","type":"string","label":"Period labels (comma separated)","default":"2019, 2020, 2021, 2022, 2023, 2024"},
        {"id":"series","type":"string","label":"Series (one per line: Name: v1, v2, ...)","default":"Northwind: 42, 58, 71, 96, 118, 131\nCobalt: 30, 46, 68, 92, 126, 168\nFerry: 55, 62, 66, 70, 74, 79\nMarlow: 18, 33, 52, 61, 88, 104\nAster: 25, 28, 44, 58, 63, 72\nPell: 12, 20, 39, 47, 55, 90\nQuill: 8, 11, 15, 24, 40, 66\nDunmore: 35, 37, 38, 40, 42, 44"},
        {"id":"barCount","type":"number","label":"Bars shown","default":6,"min":3,"max":12,"step":1},
        {"id":"periodDuration","type":"number","label":"Seconds per period","default":2,"min":0.4,"max":6,"step":0.1},
        {"id":"valuePrefix","type":"string","label":"Value prefix","default":"$"},
        {"id":"valueSuffix","type":"string","label":"Value suffix","default":"M"},
        {"id":"valueDecimals","type":"number","label":"Value decimals","default":0,"min":0,"max":3,"step":1},
        {"id":"accent","type":"color","label":"Leader accent","default":"#c8452d"}
      ]'
    >
      <div id="bcr-stage" class="clip" data-start="0" data-duration="12" data-track-index="0">
        <div id="bcr-bg"></div>

        <div id="bcr-head">
          <div id="bcr-head-left">
            <h1 id="bcr-title">Streaming Subscribers by Service</h1>
            <p id="bcr-subtitle">Ranked by reported subscribers</p>
          </div>
          <div id="bcr-head-right">
            <span id="bcr-period-caption">Period</span>
            <span id="bcr-period">2019</span>
          </div>
        </div>

        <!-- Rows live here, one per series, positioned by rank. -->
        <div id="bcr-plot"></div>

        <!-- Axis is painted above the rows: the tick labels sliding left is the
             visible evidence that the scale is rescaling as values climb. -->
        <div id="bcr-axis"></div>

        <p id="bcr-source">Placeholder data</p>
      </div>

      <style>
        #bcr-root {
          position: relative;
          width: 1920px;
          height: 1080px;
          overflow: hidden;
          font-family: Inter, sans-serif;
          color: #1f1d1b;
        }

        #bcr-stage {
          position: absolute;
          inset: 0;
        }

        /* Scene fill goes on a full-bleed child, never the composition root. */
        #bcr-bg {
          position: absolute;
          inset: 0;
          background-color: #f5f3ef;
        }

        #bcr-head {
          position: absolute;
          top: 58px;
          left: 96px;
          width: 1728px;
          display: flex;
          align-items: flex-start;
          justify-content: space-between;
        }

        #bcr-title {
          font-size: 46px;
          font-weight: 700;
          letter-spacing: -0.015em;
          line-height: 1.1;
        }

        #bcr-subtitle {
          margin-top: 10px;
          font-size: 19px;
          font-weight: 400;
          color: #6b6560;
        }

        #bcr-head-right {
          display: flex;
          flex-direction: column;
          align-items: flex-end;
        }

        #bcr-period-caption {
          font-size: 15px;
          font-weight: 600;
          letter-spacing: 0.18em;
          text-transform: uppercase;
          color: #6b6560;
        }

        #bcr-period {
          margin-top: 4px;
          font-size: 72px;
          font-weight: 700;
          line-height: 1;
          font-variant-numeric: tabular-nums;
          letter-spacing: -0.02em;
        }

        #bcr-plot {
          position: absolute;
          left: 0;
          top: 236px;
          width: 1920px;
          height: 760px;
          overflow: hidden;
        }

        .bcr-row {
          position: absolute;
          left: 0;
          top: 0;
          width: 1920px;
        }

        /* Rows are transparent so both bars of an overtake stay visible sliding
           past each other; only the two TEXT blocks carry an opaque backing, so
           the front row's label covers the one it crosses instead of tangling
           with it. */
        .bcr-name {
          position: absolute;
          left: 96px;
          width: 272px;
          top: 50%;
          transform: translateY(-50%);
          text-align: right;
          font-size: 26px;
          font-weight: 600;
          line-height: 1.7;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          background-color: #f5f3ef;
        }

        .bcr-bar {
          position: absolute;
          left: 400px;
          top: 50%;
          transform: translateY(-50%);
          border-radius: 3px;
          background-color: #1f1d1b;
        }

        .bcr-value {
          position: absolute;
          left: 404px;
          top: 50%;
          padding: 0 12px;
          font-size: 26px;
          font-weight: 600;
          line-height: 1.7;
          font-variant-numeric: tabular-nums;
          white-space: nowrap;
          background-color: #f5f3ef;
        }

        #bcr-axis {
          position: absolute;
          inset: 0;
          z-index: 5000;
        }

        .bcr-tick-line {
          position: absolute;
          top: 232px;
          left: 0;
          width: 1px;
          height: 768px;
          background-color: rgba(31, 29, 27, 0.11);
        }

        .bcr-tick-line.bcr-tick-zero {
          background-color: rgba(31, 29, 27, 0.5);
        }

        .bcr-tick-label {
          position: absolute;
          top: 198px;
          left: 0;
          font-size: 17px;
          font-weight: 500;
          font-variant-numeric: tabular-nums;
          color: #6b6560;
          white-space: nowrap;
        }

        #bcr-source {
          position: absolute;
          left: 96px;
          top: 1014px;
          font-size: 16px;
          color: #6b6560;
        }
      </style>

      <script>
        (function () {
          "use strict";

          // ---- variables -------------------------------------------------
          // The declaration attribute is the single source of truth: when the
          // runtime is absent (raw file open) we parse the same attribute.
          function readVars() {
            // Read the declaration off THIS block's own root, never
            // document.documentElement: mounted as a sub-composition the block's
            // <html> is discarded and documentElement is the HOST's, so a
            // documentElement read returns the wrong element (or null) and the
            // block renders with no series at all. Runtime / host / --variables
            // values are layered on top as overrides, so the block does not
            // depend on the runtime being injected before this script runs.
            var out = {};
            var root = document.getElementById("bcr-root");
            try {
              JSON.parse(root.getAttribute("data-composition-variables")).forEach(function (d) {
                out[d.id] = d.default;
              });
            } catch (err) {
              // Declaration unreadable; the per-variable literal fallbacks below
              // still produce a renderable composition.
            }
            var hf = window.__hyperframes;
            var overrides =
              hf && typeof hf.getVariables === "function"
                ? hf.getVariables()
                : window.__hfVariables;
            if (overrides && typeof overrides === "object") {
              for (var key in overrides) {
                if (overrides[key] !== undefined) out[key] = overrides[key];
              }
            }
            return out;
          }

          function num(value, fallback, lo, hi) {
            var n = typeof value === "number" ? value : Number.parseFloat(value);
            if (!Number.isFinite(n)) n = fallback;
            return Math.min(hi, Math.max(lo, n));
          }

          function str(value, fallback) {
            return typeof value === "string" && value.length > 0 ? value : fallback;
          }

          var V = readVars();
          var ACCENT = str(V.accent, "#c8452d");
          var BAR_COLOR = "#1f1d1b";
          var BAR_COUNT = Math.round(num(V.barCount, 6, 1, 24));
          var PERIOD_DURATION = num(V.periodDuration, 2, 0.1, 30);
          var DECIMALS = Math.round(num(V.valueDecimals, 0, 0, 6));
          var PREFIX = typeof V.valuePrefix === "string" ? V.valuePrefix : "";
          var SUFFIX = typeof V.valueSuffix === "string" ? V.valueSuffix : "";

          // ---- data format ------------------------------------------------
          // Wide table, one series per line: "Name: v1, v2, v3".
          // `;` is accepted as a line separator so the whole table survives a
          // single-line text input. Short rows hold their last value.
          function parsePeriods(text) {
            return String(text)
              .split(",")
              .map(function (s) {
                return s.trim();
              })
              .filter(function (s) {
                return s.length > 0;
              });
          }

          function parseSeries(text, periodCount) {
            var out = [];
            var lines = String(text).split(/[\n;]+/);
            for (var i = 0; i < lines.length; i++) {
              var line = lines[i].trim();
              if (!line) continue;
              var split = line.indexOf(":");
              if (split < 0) continue;
              var label = line.slice(0, split).trim();
              if (!label) continue;
              var values = line
                .slice(split + 1)
                .split(",")
                .map(function (s) {
                  return Number.parseFloat(s.trim());
                })
                .filter(function (n) {
                  return Number.isFinite(n);
                });
              if (values.length === 0) continue;
              while (values.length < periodCount) values.push(values[values.length - 1]);
              out.push({ label: label, values: values.slice(0, periodCount) });
            }
            return out;
          }

          var PERIODS = parsePeriods(str(V.periods, "2019, 2020, 2021, 2022, 2023, 2024"));
          if (PERIODS.length === 0) PERIODS = ["1"];
          var SERIES = parseSeries(str(V.series, ""), PERIODS.length);
          var T = PERIODS.length;
          var N = SERIES.length;

          // ---- closed-form state ------------------------------------------
          // k = 10 interpolated keyframes per period (Bostock's number). Rank is
          // computed at each keyframe from the interpolated value and baked once;
          // a bar's on-screen row is then solved FROM rank, so two bars can never
          // cross without swapping. Everything below is a pure function of t.
          var K = 10;
          var KF_DURATION = PERIOD_DURATION / K;
          var KF_COUNT = T > 1 ? (T - 1) * K + 1 : 1;
          var RACE_SECONDS = T > 1 ? (T - 1) * PERIOD_DURATION : 0;

          function clamp(v, lo, hi) {
            return v < lo ? lo : v > hi ? hi : v;
          }

          function valueAt(t, series) {
            if (T < 2) return series.values[0];
            var u = t / PERIOD_DURATION;
            var i = clamp(Math.floor(u), 0, T - 2);
            var f = clamp(u - i, 0, 1);
            return series.values[i] + (series.values[i + 1] - series.values[i]) * f;
          }

          // ranks[m][j] = integer rank of series j at keyframe m (0 = leader).
          var RANKS = [];
          for (var m = 0; m < KF_COUNT; m++) {
            var tm = m * KF_DURATION;
            var order = SERIES.map(function (s, j) {
              return { j: j, v: valueAt(tm, s) };
            });
            // Descending by value; index breaks ties so the sort is total and
            // therefore identical on every evaluation.
            order.sort(function (a, b) {
              return b.v - a.v || a.j - b.j;
            });
            var row = new Array(N);
            for (var r = 0; r < order.length; r++) row[order[r].j] = r;
            RANKS.push(row);
          }

          function smoothstep(x) {
            return x * x * (3 - 2 * x);
          }

          // Continuous row position solved from the baked ranks. A rank change
          // between two adjacent keyframes IS the swap, and it plays out over one
          // keyframe interval (PERIOD_DURATION / 10) instead of snapping.
          function rankPosAt(t, j) {
            if (KF_COUNT < 2) return RANKS[0][j];
            var m = t / KF_DURATION;
            var m0 = clamp(Math.floor(m), 0, KF_COUNT - 2);
            var e = smoothstep(clamp(m - m0, 0, 1));
            var a = RANKS[m0][j];
            var b = RANKS[m0 + 1][j];
            return a + (b - a) * e;
          }

          // ---- formatting --------------------------------------------------
          function formatValue(v) {
            return (
              PREFIX +
              v.toLocaleString("en-US", {
                minimumFractionDigits: DECIMALS,
                maximumFractionDigits: DECIMALS,
              }) +
              SUFFIX
            );
          }

          function hexToRgb(hex) {
            var h = String(hex).trim().replace("#", "");
            if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
            var n = Number.parseInt(h, 16);
            if (!Number.isFinite(n)) return [31, 29, 27];
            return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
          }

          // Accent is the only colour in the piece and it carries exactly one
          // meaning: this bar currently leads. It is binary, not blended — a
          // half-accent bar would assert "half leading", which is not a fact.
          // It hands over at the instant the two values cross, which is also the
          // instant the two bars are the same length, so the handover lands on
          // the overtake instead of drifting near it. Flat fill, never a
          // gradient: a gradient inside a bar makes the longest bar the palest
          // exactly where the eye is comparing lengths.
          function hexToCss(hex) {
            var rgb = hexToRgb(hex);
            return "rgb(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ")";
          }

          var BAR_FILL = hexToCss(BAR_COLOR);
          var ACCENT_FILL = hexToCss(ACCENT);

          function niceStep(x) {
            var e = Math.pow(10, Math.floor(Math.log10(x)));
            var f = x / e;
            return e * (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10);
          }

          // ---- geometry ----------------------------------------------------
          var TRACK_X = 400;
          var TRACK_W = 1180;
          var PLOT_H = 760;
          var PITCH = PLOT_H / BAR_COUNT;
          var BAR_H = Math.max(12, PITCH * 0.62);
          var TICK_POOL = 12;

          // ---- DOM ---------------------------------------------------------
          var plot = document.getElementById("bcr-plot");
          var axis = document.getElementById("bcr-axis");
          var periodEl = document.getElementById("bcr-period");
          document.getElementById("bcr-title").textContent = str(
            V.title,
            "Streaming Subscribers by Service",
          );
          document.getElementById("bcr-subtitle").textContent = str(
            V.subtitle,
            "Ranked by reported subscribers",
          );

          var rows = SERIES.map(function (s) {
            var row = document.createElement("div");
            row.className = "bcr-row";
            row.style.height = PITCH + "px";

            // Declared, not silenced: two rows mid-overtake genuinely occupy the
            // same band for ~200ms. Readability is handled by the opaque chip on
            // each label, so the front one is always legible.
            var name = document.createElement("div");
            name.className = "bcr-name";
            name.setAttribute("data-layout-allow-overlap", "");
            name.textContent = s.label;

            var bar = document.createElement("div");
            bar.className = "bcr-bar";
            bar.style.height = BAR_H + "px";

            var value = document.createElement("div");
            value.className = "bcr-value";
            value.setAttribute("data-layout-allow-overlap", "");

            row.appendChild(name);
            row.appendChild(bar);
            row.appendChild(value);
            plot.appendChild(row);
            return { row: row, bar: bar, value: value };
          });

          var ticks = [];
          for (var k = 0; k < TICK_POOL; k++) {
            var line = document.createElement("div");
            line.className = "bcr-tick-line" + (k === 0 ? " bcr-tick-zero" : "");
            var label = document.createElement("div");
            label.className = "bcr-tick-label";
            axis.appendChild(line);
            axis.appendChild(label);
            ticks.push({ line: line, label: label });
          }

          // ---- render ------------------------------------------------------
          function render(t) {
            if (N === 0) return;

            var values = SERIES.map(function (s) {
              return valueAt(t, s);
            });
            // Paint order follows the CURRENT value, not the row position: the
            // bar that has just edged ahead comes forward, so at the instant two
            // bars are superimposed the frame shows the one that is overtaking
            // (its bar and its label together) rather than the one it passed.
            var byValue = values
              .map(function (v, i) {
                return i;
              })
              .sort(function (a, b) {
                return values[b] - values[a] || a - b;
              });
            var zRank = new Array(values.length);
            for (var q = 0; q < byValue.length; q++) zRank[byValue[q]] = q;
            var leader = byValue[0];
            var maxValue = values[leader];
            // Headroom keeps the leader off the frame edge; the domain is a
            // continuous function of t, so the axis glides instead of stepping.
            var scaleMax = Math.max(maxValue * 1.06, 1e-6);
            var step = niceStep(scaleMax / 4);

            for (var i = 0; i < TICK_POOL; i++) {
              var tv = i * step;
              var visible = tv <= scaleMax;
              var tx = TRACK_X + (tv / scaleMax) * TRACK_W;
              ticks[i].line.style.transform = "translateX(" + tx.toFixed(2) + "px)";
              ticks[i].line.style.opacity = visible ? "1" : "0";
              ticks[i].label.style.transform =
                "translateX(" + tx.toFixed(2) + "px) translateX(-50%)";
              ticks[i].label.style.opacity = visible ? "1" : "0";
              ticks[i].label.textContent = visible ? formatValue(tv) : "";
            }

            for (var j = 0; j < rows.length; j++) {
              var rankPos = rankPosAt(t, j);
              var barW = (values[j] / scaleMax) * TRACK_W;
              var el = rows[j];

              el.row.style.transform = "translateY(" + (rankPos * PITCH).toFixed(2) + "px)";
              // Fades out only as it slides past the last visible slot.
              el.row.style.opacity = clamp(BAR_COUNT - rankPos, 0, 1).toFixed(3);
              el.row.style.zIndex = String(1000 - zRank[j]);

              el.bar.style.width = barW.toFixed(2) + "px";
              el.bar.style.backgroundColor = j === leader ? ACCENT_FILL : BAR_FILL;

              el.value.style.transform = "translate(" + barW.toFixed(2) + "px, -50%)";
              el.value.textContent = formatValue(values[j]);
            }

            var pIndex = clamp(Math.floor(t / PERIOD_DURATION), 0, T - 1);
            periodEl.textContent = PERIODS[pIndex];
          }

          // ---- timeline ----------------------------------------------------
          // A property SETTER drives the per-frame work: tl.eventCallback("onUpdate")
          // does not fire on tl.seek(), a tweened accessor does.
          var driver = { _t: 0 };
          Object.defineProperty(driver, "t", {
            get: function () {
              return this._t;
            },
            set: function (value) {
              this._t = value;
              render(value);
            },
          });

          var tl = gsap.timeline({ paused: true });
          if (RACE_SECONDS > 0) {
            tl.to(driver, { t: RACE_SECONDS, duration: RACE_SECONDS, ease: "none" }, 0);
          }
          render(0);

          window.__timelines = window.__timelines || {};
          window.__timelines["bar-chart-race"] = tl;
        })();
      </script>
    </div>
  </body>
</html>
```

Copy npx hyperframes add bar-chart-race to the clipboard

That writes one file: `compositions/bar-chart-race.html`.

## Add it to your video

It runs for 12 seconds at 1920×1080. Paste this into your composition:

```html index.html
<div
  data-composition-id="bar-chart-race"
  data-composition-src="compositions/bar-chart-race.html"
  data-start="0"
  data-duration="12"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/data-attributes.md) for the rest.

## Change how it looks

Set these CSS variables on the block:

* `title` — Title. Defaults to `Streaming Subscribers by Service`.
* `subtitle` — Subtitle. Defaults to `Ranked by reported subscribers`.
* `periods` — Period labels (comma separated). Defaults to `2019, 2020, 2021, 2022, 2023, 2024`.
* `series` — Series (one per line: Name: v1, v2, ...). Defaults to `Northwind: 42, 58, 71, 96, 118, 131
  Cobalt: 30, 46, 68, 92, 126, 168
  Ferry: 55, 62, 66, 70, 74, 79
  Marlow: 18, 33, 52, 61, 88, 104
  Aster: 25, 28, 44, 58, 63, 72
  Pell: 12, 20, 39, 47, 55, 90
  Quill: 8, 11, 15, 24, 40, 66
  Dunmore: 35, 37, 38, 40, 42, 44`.
* `barCount` — Bars shown. Defaults to `6`.
* `periodDuration` — Seconds per period. Defaults to `2`.
* `valuePrefix` — Value prefix. Defaults to `$`.
* `valueSuffix` — Value suffix. Defaults to `M`.
* `valueDecimals` — Value decimals. Defaults to `0`.
* `accent` — Leader accent. Defaults to `#c8452d`.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable | Default | Accepts | What it does |
| - | - | - | - |
| `title` | `Streaming Subscribers by Service` | string | |
| `subtitle` | `Ranked by reported subscribers` | string | |
| `periods` | `2019, 2020, 2021, 2022, 2023, 2024` | string | |
| `series` | \`Northwind: 42, 58, 71, 96, 118, 131 | | |
| Cobalt: 30, 46, 68, 92, 126, 168 | | | |
| Ferry: 55, 62, 66, 70, 74, 79 | | | |
| Marlow: 18, 33, 52, 61, 88, 104 | | | |
| Aster: 25, 28, 44, 58, 63, 72 | | | |
| Pell: 12, 20, 39, 47, 55, 90 | | | |
| Quill: 8, 11, 15, 24, 40, 66 | | | |
| Dunmore: 35, 37, 38, 40, 42, 44\` | string | | |
| `barCount` | `6` | 3 to 12, step 1 | |
| `periodDuration` | `2` | 0.4 to 6, step 0.1 | |
| `valuePrefix` | `$` | string | |
| `valueSuffix` | `M` | string | |
| `valueDecimals` | `0` | 0 to 3, step 1 | |
| `accent` | `#c8452d` | color | |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap
<div
  data-composition-id="bar-chart-race"
  data-composition-src="compositions/bar-chart-race.html"
  data-variable-values='{"title":"Streaming Subscribers by Service","subtitle":"Ranked by reported subscribers","periods":"2019, 2020, 2021, 2022, 2023, 2024","series":"Northwind: 42, 58, 71, 96, 118, 131\nCobalt: 30, 46, 68, 92, 126, 168\nFerry: 55, 62, 66, 70, 74, 79\nMarlow: 18, 33, 52, 61, 88, 104\nAster: 25, 28, 44, 58, 63, 72\nPell: 12, 20, 39, 47, 55, 90\nQuill: 8, 11, 15, 24, 40, 66\nDunmore: 35, 37, 38, 40, 42, 44","barCount":6,"periodDuration":2,"valuePrefix":"$","valueSuffix":"M","valueDecimals":0,"accent":"#c8452d"}'
></div>
```

{/* hf:generated-footer */}

Tagged `data` `chart` `statistics` `ranking`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)
