{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bar-race",
  "type": "registry:block",
  "title": "Bar Chart Race",
  "description": "A bar chart race whose values glide on a monotone curve while bars re-rank on velocity-aware springs, lifting as they overtake; the leader takes the accent and the period counter rolls like an odometer. Play/pause, a spring-following scrubber and keyboard control, and it loops cleanly.",
  "docs": "Bar Chart Race was added to public/vividsites/bar-race.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/bar-race",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/bar-race.html",
      "type": "registry:file",
      "target": "public/vividsites/bar-race.html",
      "content": "<!--\n  vs-bar-race: a bar chart race. Values glide between periods on a monotone cubic curve (no kinks at\n  each year), bars re-rank on velocity-aware springs, and a bar that overtakes lifts off the chart with\n  a shadow while it passes. The leader takes the accent colour as it reaches the top slot, the grid\n  re-scales with fading ticks, and the big period counter rolls its digits like an odometer.\n  Built-in play/pause and a scrubber (drag it, the chart follows on a spring). It loops: it holds on\n  the last period, then rewinds to the first one on a spring and plays again.\n\n  Markup:\n    <figure class=\"vs-bar-race\" aria-label=\"Container throughput, top ports, 2014 to 2025\">\n      <figcaption class=\"vs-bar-race-head\">\n        <span class=\"vs-bar-race-title\">Container throughput</span>\n        <span class=\"vs-bar-race-sub\">Top ports · million TEU</span>\n      </figcaption>\n      <script type=\"application/json\">{\"periods\":[2014,2015], \"decimals\":1, \"unit\":\"M TEU\",\n        \"series\":[{\"name\":\"Port Aldren\",\"values\":[33.6,34.9]}, …]}</script>\n    </figure>\n  The figcaption is optional. Every series needs one value per period.\n  Root attributes:\n    data-vs-bar-race-bars       bars shown (8); the rest wait below the chart and slide in when they rank\n    data-vs-bar-race-theme      \"light\" (default) | \"ink\"\n    data-vs-bar-race-autoplay   \"false\" to start paused (default plays once it scrolls into view)\n    data-vs-bar-race-loop       \"false\" to stop on the last period\n  Variables:\n    --vs-bar-race-accent    the leader's bar                     (#ff5a1f)\n    --vs-bar-race-speed     periods per second while playing     (.9)  read live\n    --vs-bar-race-hold      pause on the last period, seconds    (1.2)\n    --vs-bar-race-plot      plot height                          (420px)\n    --vs-bar-race-radius    card radius                          (22px)\n    --vs-bar-race-font      labels face                          (inherit)\n    --vs-bar-race-display   figures + period counter face        (inherit)\n    --vs-bar-race-surface / -ink / -muted / -line / -bar        colours (see the theme blocks)\n  JS: el.vsBarRace.play() .pause() .toggle() .seek(indexOrPeriod) .index (float)\n  Events (on the root): \"vs-bar-race:period\" {period, index} when the counter changes,\n                        \"vs-bar-race:loop\" when it restarts from the first period.\n  Keyboard: the scrubber is a slider (arrows step a period, Home / End); Space plays / pauses anywhere\n  inside the chart. Screen readers get a hidden table of every value, and the ranking of the current\n  period is announced whenever the chart is paused or scrubbed.\n  prefers-reduced-motion: no autoplay, it renders the last period; scrubbing moves instantly.\n-->\n<style>\n.vs-bar-race {\n  --vs-bar-race-accent: #ff5a1f;\n  --vs-bar-race-speed: .9;\n  --vs-bar-race-hold: 1.2;\n  --vs-bar-race-plot: 420px;\n  --vs-bar-race-radius: 22px;\n  --vs-bar-race-font: inherit;\n  --vs-bar-race-display: inherit;\n  --vs-bar-race-surface: #f9fafb;\n  --vs-bar-race-ink: #14171c;\n  --vs-bar-race-muted: rgba(20,23,28,.5);\n  --vs-bar-race-line: rgba(20,23,28,.075);\n  --vs-bar-race-bar: #1d2330;\n  --vs-bar-race-gutter: 150px;\n\n  position: relative; box-sizing: border-box; margin: 0; width: 100%;\n  container-type: inline-size;\n  padding: 26px 30px 20px;\n  border-radius: var(--vs-bar-race-radius);\n  background: var(--vs-bar-race-surface);\n  color: var(--vs-bar-race-ink);\n  font-family: var(--vs-bar-race-font);\n  box-shadow: 0 0 0 .5px rgba(20,24,32,.12), inset 0 1px 0 rgba(255,255,255,.9),\n    0 1px 2px rgba(20,24,32,.05), 0 12px 32px -14px rgba(20,24,32,.2), 0 44px 90px -40px rgba(20,24,32,.24);\n  -webkit-font-smoothing: antialiased; user-select: none; -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n}\n.vs-bar-race *, .vs-bar-race *::before, .vs-bar-race *::after { box-sizing: border-box; }\n.vs-bar-race[data-vs-bar-race-theme=\"ink\"] {\n  --vs-bar-race-surface: #121418;\n  --vs-bar-race-ink: #eef0f3;\n  --vs-bar-race-muted: rgba(238,240,243,.5);\n  --vs-bar-race-line: rgba(238,240,243,.075);\n  --vs-bar-race-bar: #d5dae2;\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.1), inset 0 1px 0 rgba(255,255,255,.06),\n    0 1px 2px rgba(0,0,0,.3), 0 14px 36px -14px rgba(0,0,0,.6), 0 44px 90px -40px rgba(0,0,0,.7);\n}\n\n/* head */\n.vs-bar-race-head { display: flex; align-items: baseline; gap: 4px 12px; flex-wrap: wrap; margin: 0 0 30px; }\n.vs-bar-race-title { font-weight: 500; font-size: 17px; letter-spacing: -.01em; }\n.vs-bar-race-sub { font-size: 13px; color: var(--vs-bar-race-muted); }\n\n/* plot */\n.vs-bar-race-plot { position: relative; height: var(--vs-bar-race-plot); margin-top: 24px; }\n.vs-bar-race-axis { position: absolute; inset: -24px 0 0 0; pointer-events: none; }\n.vs-bar-race-tick { position: absolute; left: 0; top: 0; bottom: 0; width: 0; will-change: transform, opacity; }\n.vs-bar-race-tick::before { content: \"\"; position: absolute; left: 0; top: 22px; bottom: 0; width: 1px; transform: translateX(-.5px); background: var(--vs-bar-race-line); }\n.vs-bar-race-tick span {\n  position: absolute; top: 0; left: 0; transform: translateX(-50%);\n  font-family: var(--vs-bar-race-display); font-weight: 500; font-size: 11px; line-height: 14px; letter-spacing: .02em; color: var(--vs-bar-race-muted);\n  font-variant-numeric: tabular-nums; white-space: nowrap;\n}\n.vs-bar-race-tick.is-zero::before { background: color-mix(in srgb, var(--vs-bar-race-ink) 22%, transparent); }\n.vs-bar-race-tick.is-zero span { display: none; }\n\n.vs-bar-race-year {\n  position: absolute; right: -2px; bottom: -6px; display: flex; pointer-events: none;\n  font-family: var(--vs-bar-race-display); font-weight: 700; line-height: 1; letter-spacing: -.02em;\n  font-size: min(calc(var(--vs-bar-race-plot) * .34), 25cqw);\n  color: var(--vs-bar-race-ink); opacity: .085; font-variant-numeric: tabular-nums;\n}\n.vs-bar-race-dcol { position: relative; display: block; height: 1em; overflow: hidden; }\n.vs-bar-race-dstrip { display: flex; flex-direction: column; will-change: transform; }\n.vs-bar-race-dstrip > span { display: block; height: 1em; line-height: 1; text-align: center; }\n\n.vs-bar-race-rank { position: absolute; left: 0; top: 0; bottom: 0; width: 28px; pointer-events: none; }\n.vs-bar-race-rank span {\n  position: absolute; left: 0; font-family: var(--vs-bar-race-display); font-weight: 500; font-size: 12px; line-height: 1; color: var(--vs-bar-race-muted);\n  font-variant-numeric: tabular-nums; opacity: .7; transform: translateY(-50%);\n  transition: opacity .3s;\n}\n.vs-bar-race-rank span:first-child { color: var(--vs-bar-race-accent); opacity: 1; }\n\n.vs-bar-race-rows { position: absolute; inset: 0; clip-path: inset(-30px -80px 0 -80px); }\n.vs-bar-race-row {\n  position: absolute; left: 0; right: 0; top: 0; will-change: transform, opacity;\n  transform-origin: var(--vs-bar-race-gutter) 50%;\n}\n.vs-bar-race-name {\n  position: absolute; left: 30px; width: calc(var(--vs-bar-race-gutter) - 44px); top: 50%; transform: translateY(-50%);\n  text-align: right; font-size: 14px; font-weight: 500; letter-spacing: -.005em; line-height: 1.1;\n  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.vs-bar-race-bar {\n  position: absolute; left: var(--vs-bar-race-gutter); top: 0; overflow: hidden;\n  border-radius: 0 7px 7px 0;\n}\n.vs-bar-race-fill { position: absolute; inset: 0; border-radius: 0 7px 7px 0; will-change: transform; overflow: hidden; background: var(--vs-bar-race-surface); }\n.vs-bar-race-fill > i { position: absolute; inset: 0; will-change: opacity; }\n.vs-bar-race-fill > i:first-child { background: linear-gradient(180deg, color-mix(in srgb, var(--vs-bar-race-bar) 86%, #fff), var(--vs-bar-race-bar)); }\n.vs-bar-race-fill > i:nth-child(2) {\n  background: linear-gradient(90deg, color-mix(in srgb, var(--vs-bar-race-accent) 82%, #000) 0%, var(--vs-bar-race-accent) 70%, color-mix(in srgb, var(--vs-bar-race-accent) 82%, #fff) 100%);\n  opacity: 0;\n}\n/* an inner top highlight + bottom edge on every bar */\n.vs-bar-race-fill::after {\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 -1px 0 rgba(0,0,0,.14);\n}\n/* hover: the hovered bar stays, the rest step back */\n.vs-bar-race-name, .vs-bar-race-bar, .vs-bar-race-val { transition: opacity .25s cubic-bezier(.2,0,0,1); }\n.vs-bar-race.is-hover .vs-bar-race-row:not(.is-hot) :is(.vs-bar-race-name, .vs-bar-race-bar, .vs-bar-race-val) { opacity: .4; }\n/* lift shadow while a bar overtakes */\n.vs-bar-race-sh {\n  position: absolute; left: var(--vs-bar-race-gutter); top: 0; border-radius: 0 8px 8px 0; opacity: 0; pointer-events: none;\n  box-shadow: 0 6px 14px -4px rgba(10,14,22,.35), 0 16px 34px -10px rgba(10,14,22,.3);\n}\n.vs-bar-race[data-vs-bar-race-theme=\"ink\"] .vs-bar-race-sh { box-shadow: 0 6px 16px -4px rgba(0,0,0,.7), 0 18px 38px -10px rgba(0,0,0,.6); }\n.vs-bar-race-val {\n  position: absolute; left: 0; top: 50%; margin-top: -.5em; line-height: 1;\n  font-family: var(--vs-bar-race-display); font-weight: 500; font-size: 15px; line-height: 1; letter-spacing: .01em; font-variant-numeric: tabular-nums;\n  white-space: nowrap; will-change: transform;\n}\n\n/* controls */\n.vs-bar-race-ctrl { display: flex; align-items: center; gap: 16px; margin-top: 22px; }\n.vs-bar-race-play {\n  all: unset; box-sizing: border-box; position: relative; flex: none; width: 40px; height: 40px; border-radius: 50%;\n  display: grid; place-items: center; cursor: pointer;\n  background: var(--vs-bar-race-ink); color: var(--vs-bar-race-surface);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 1px 2px rgba(0,0,0,.18), 0 6px 14px -6px rgba(0,0,0,.4);\n  transition: transform .35s cubic-bezier(.34,1.56,.64,1), box-shadow .2s;\n}\n.vs-bar-race-play:hover { transform: scale(1.06); }\n.vs-bar-race-play:active { transform: scale(.92); transition-duration: .1s; }\n.vs-bar-race-play:focus-visible, .vs-bar-race-scrub:focus-visible .vs-bar-race-knob { outline: 2px solid var(--vs-bar-race-accent); outline-offset: 3px; }\n.vs-bar-race-play svg { grid-area: 1 / 1; width: 16px; height: 16px; transition: opacity .2s, transform .4s cubic-bezier(.34,1.56,.64,1); }\n.vs-bar-race-play .vs-bar-race-i-play { transform: translateX(1px); }\n.vs-bar-race[data-playing] .vs-bar-race-i-play, .vs-bar-race:not([data-playing]) .vs-bar-race-i-pause { opacity: 0; transform: scale(.4) rotate(-40deg); }\n\n.vs-bar-race-scrub { position: relative; flex: 1; height: 40px; cursor: pointer; touch-action: none; outline: none; }\n.vs-bar-race-rail { position: absolute; left: 0; right: 0; top: 13px; height: 4px; border-radius: 4px; background: var(--vs-bar-race-line); overflow: hidden; box-shadow: inset 0 0 0 .5px var(--vs-bar-race-line); }\n.vs-bar-race-rail > i { position: absolute; inset: 0; background: var(--vs-bar-race-ink); transform-origin: 0 50%; will-change: transform; border-radius: 4px; }\n.vs-bar-race-dots { position: absolute; left: 0; right: 0; top: 15px; height: 0; }\n.vs-bar-race-dots > i { position: absolute; top: -1px; width: 2px; height: 2px; margin-left: -1px; border-radius: 50%; background: var(--vs-bar-race-surface); opacity: .75; }\n.vs-bar-race-ends { position: absolute; left: 0; right: 0; top: 26px; display: flex; justify-content: space-between; font-family: var(--vs-bar-race-display); font-weight: 500; font-size: 11px; line-height: 1; color: var(--vs-bar-race-muted); font-variant-numeric: tabular-nums; letter-spacing: .03em; }\n.vs-bar-race-knob {\n  position: absolute; left: 0; top: 15px; width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%;\n  background: #fff; will-change: transform;\n  box-shadow: 0 0 0 .5px rgba(0,0,0,.18), 0 1px 3px rgba(0,0,0,.2), 0 4px 10px -2px rgba(0,0,0,.18);\n}\n.vs-bar-race-knob::after { content: \"\"; position: absolute; inset: 5px; border-radius: 50%; background: var(--vs-bar-race-accent); transform: scale(0); transition: transform .35s cubic-bezier(.34,1.56,.64,1); }\n.vs-bar-race-scrub:hover .vs-bar-race-knob::after, .vs-bar-race-scrub.is-drag .vs-bar-race-knob::after { transform: scale(1); }\n\n.vs-bar-race-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n\n@container (max-width: 560px) {\n  .vs-bar-race-rank { display: none; }\n  .vs-bar-race-name { left: 0; width: calc(var(--vs-bar-race-gutter) - 10px); font-size: 12px; }\n  .vs-bar-race-val { font-size: 13px; }\n  .vs-bar-race-ctrl { gap: 12px; }\n}\n</style>\n<figure class=\"vs-bar-race\" data-vs-bar-race-bars=\"8\" aria-label=\"Container throughput of the top ports, 2014 to 2025\">\n  <figcaption class=\"vs-bar-race-head\">\n    <span class=\"vs-bar-race-title\">Container throughput</span>\n    <span class=\"vs-bar-race-sub\">Busiest ports · million TEU</span>\n  </figcaption>\n  <script type=\"application/json\">{\n    \"periods\": [2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025],\n    \"unit\": \"million TEU\", \"decimals\": 1,\n    \"series\": [\n      {\"name\": \"Port Aldren\",   \"values\": [33.6, 34.9, 35.2, 37.4, 39.1, 40.6, 38.2, 42.3, 44.0, 44.9, 46.1, 47.3]},\n      {\"name\": \"Veyra\",         \"values\": [24.1, 26.0, 28.3, 30.9, 33.8, 36.9, 36.1, 41.7, 45.2, 47.8, 50.6, 53.4]},\n      {\"name\": \"Kestrel Bay\",   \"values\": [30.2, 30.8, 31.1, 31.5, 31.2, 30.7, 27.9, 29.4, 29.0, 28.6, 28.1, 27.7]},\n      {\"name\": \"Oloron\",        \"values\": [18.3, 19.6, 21.2, 22.8, 24.9, 26.4, 25.8, 28.9, 30.4, 31.9, 33.0, 34.2]},\n      {\"name\": \"Saltmere\",      \"values\": [21.4, 21.9, 22.6, 23.0, 23.5, 23.9, 22.1, 23.8, 24.2, 24.0, 24.6, 25.1]},\n      {\"name\": \"Harrow Cross\",  \"values\": [11.8, 13.4, 15.3, 17.6, 19.8, 22.3, 23.4, 26.8, 28.1, 29.9, 31.6, 33.5]},\n      {\"name\": \"Linden Falls\",  \"values\": [16.2, 16.9, 17.3, 17.1, 17.8, 18.4, 16.6, 17.9, 18.3, 18.9, 19.4, 19.8]},\n      {\"name\": \"Marisco Sound\", \"values\": [8.9, 9.6, 10.8, 12.1, 13.9, 15.6, 16.2, 18.8, 20.9, 22.7, 24.8, 26.9]},\n      {\"name\": \"Tarrow\",        \"values\": [14.6, 14.9, 15.1, 14.7, 14.3, 14.0, 12.6, 13.2, 12.9, 12.5, 12.2, 11.9]},\n      {\"name\": \"Quillon\",       \"values\": [7.2, 7.9, 8.8, 9.9, 11.0, 12.4, 12.9, 14.6, 15.9, 17.4, 18.8, 20.3]}\n    ]\n  }</script>\n</figure>\n<script>\n(() => {\n  if (window.VsBarRace) return;\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n  const cssNum = (el, n, fb) => { const v = parseFloat(getComputedStyle(el).getPropertyValue(n)); return isNaN(v) ? fb : v; };\n  const svgPlay = '<svg class=\"vs-bar-race-i-play\" viewBox=\"0 0 16 16\" fill=\"currentColor\"><path d=\"M4.5 2.9v10.2c0 .6.66.97 1.17.65l8.1-5.1a.77.77 0 0 0 0-1.3l-8.1-5.1A.77.77 0 0 0 4.5 2.9Z\"/></svg>';\n  const svgPause = '<svg class=\"vs-bar-race-i-pause\" viewBox=\"0 0 16 16\" fill=\"currentColor\"><rect x=\"3.4\" y=\"2.6\" width=\"3.3\" height=\"10.8\" rx=\"1\"/><rect x=\"9.3\" y=\"2.6\" width=\"3.3\" height=\"10.8\" rx=\"1\"/></svg>';\n\n  // Fritsch–Carlson monotone cubic tangents, so an interpolated value never overshoots its neighbours\n  const tangents = (y) => {\n    const n = y.length, d = [], m = new Array(n).fill(0);\n    for (let i = 0; i < n - 1; i++) d.push(y[i + 1] - y[i]);\n    if (n < 2) return m;\n    m[0] = d[0]; m[n - 1] = d[n - 2];\n    for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;\n    for (let i = 0; i < n - 1; i++) {\n      if (d[i] === 0) { m[i] = m[i + 1] = 0; continue; }\n      const a = m[i] / d[i], b = m[i + 1] / d[i], s = a * a + b * b;\n      if (s > 9) { const k = 3 / Math.sqrt(s); m[i] = k * a * d[i]; m[i + 1] = k * b * d[i]; }\n    }\n    return m;\n  };\n  const hermite = (y, m, t) => {\n    const n = y.length; if (t <= 0) return y[0]; if (t >= n - 1) return y[n - 1];\n    const k = Math.floor(t), s = t - k, s2 = s * s, s3 = s2 * s;\n    return (2 * s3 - 3 * s2 + 1) * y[k] + (s3 - 2 * s2 + s) * m[k] + (-2 * s3 + 3 * s2) * y[k + 1] + (s3 - s2) * m[k + 1];\n  };\n  const niceStep = (max, want) => {\n    const raw = max / want, p = Math.pow(10, Math.floor(Math.log10(raw))), f = raw / p;\n    return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * p;\n  };\n\n  function mount(root) {\n    if (root.vsBarRace) return root.vsBarRace;\n    const src = root.querySelector('script[type=\"application/json\"]');\n    let data; try { data = JSON.parse(src.textContent); } catch (e) { return; }\n    const periods = data.periods, P = periods.length, dec = data.decimals ?? 1;\n    const fmt = new Intl.NumberFormat(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });\n    const tickFmt = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });\n    const series = data.series.map((s, i) => ({ name: s.name, y: s.values, m: tangents(s.values), i }));\n    const S = series.length;\n    const el = (tag, cls, parent, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html) e.innerHTML = html; if (parent) parent.appendChild(e); return e; };\n\n    // ── build ──\n    const plot = el('div', 'vs-bar-race-plot', null); plot.setAttribute('aria-hidden', 'true');\n    root.insertBefore(plot, src);\n    const axis = el('div', 'vs-bar-race-axis', plot);\n    const year = el('div', 'vs-bar-race-year', plot);\n    const rankCol = el('div', 'vs-bar-race-rank', plot);\n    const rowsEl = el('div', 'vs-bar-race-rows', plot);\n    const digits = String(periods[0]).length;\n    const dcols = [];\n    for (let i = 0; i < digits; i++) {\n      const c = el('span', 'vs-bar-race-dcol', year), strip = el('span', 'vs-bar-race-dstrip', c);\n      for (let d = 0; d <= 10; d++) el('span', null, strip).textContent = d % 10;\n      dcols.push({ strip, p: 0, v: 0, goal: 0 });\n    }\n    const rows = series.map((s) => {\n      const r = el('div', 'vs-bar-race-row', rowsEl);\n      const name = el('span', 'vs-bar-race-name', r); name.textContent = s.name;\n      const sh = el('span', 'vs-bar-race-sh', r);\n      const bar = el('span', 'vs-bar-race-bar', r);\n      const fill = el('span', 'vs-bar-race-fill', bar);\n      const ink = el('i', null, fill), lead = el('i', null, fill);\n      const val = el('span', 'vs-bar-race-val', r);\n      return { s, r, sh, bar, fill, ink, lead, val, y: 0, vy: 0, op: 1, rank: s.i, v: 0, txt: '' };\n    });\n    const ctrl = el('div', 'vs-bar-race-ctrl', null); root.insertBefore(ctrl, src);\n    const btn = el('button', 'vs-bar-race-play', ctrl, svgPlay + svgPause); btn.type = 'button';\n    const scrub = el('div', 'vs-bar-race-scrub', ctrl);\n    scrub.tabIndex = 0; scrub.setAttribute('role', 'slider');\n    scrub.setAttribute('aria-label', 'Period'); scrub.setAttribute('aria-valuemin', periods[0]); scrub.setAttribute('aria-valuemax', periods[P - 1]);\n    const rail = el('div', 'vs-bar-race-rail', scrub), railFill = el('i', null, rail);\n    const dots = el('div', 'vs-bar-race-dots', scrub);\n    for (let i = 1; i < P - 1; i++) el('i', null, dots).style.left = (i / (P - 1) * 100) + '%';\n    const ends = el('div', 'vs-bar-race-ends', scrub, `<span>${periods[0]}</span><span>${periods[P - 1]}</span>`);\n    const knob = el('i', 'vs-bar-race-knob', scrub);\n    // screen-reader table + live summary\n    const sr = el('div', 'vs-bar-race-sr', null); root.appendChild(sr);\n    const unit = data.unit ? ` (${data.unit})` : '';\n    sr.innerHTML = `<table><caption>${(root.getAttribute('aria-label') || 'Values') + unit}</caption><thead><tr><th scope=\"col\">Name</th>${periods.map((p) => `<th scope=\"col\">${p}</th>`).join('')}</tr></thead><tbody>${\n      series.map((s) => `<tr><th scope=\"row\">${s.name}</th>${s.y.map((v) => `<td>${fmt.format(v)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;\n    const live = el('div', null, sr); live.setAttribute('aria-live', 'polite');\n\n    // ── state ──\n    let N = 8, W = 0, trackW = 0, gutter = 150, rowH = 50, plotH = 420;\n    const baseGutter = cssNum(root, '--vs-bar-race-gutter', 150);\n    let t = reduced ? P - 1 : 0, tv = 0, tGoal = t, mode = 'idle', holdT = 0, rwFrom = 0, rwE = 0, speed = .9, holdFor = 1.2;\n    let playing = false, wantPlay = root.dataset.vsBarRaceAutoplay !== 'false' && !reduced, started = false;\n    let visible = false, raf = 0, last = 0, cfgT = 0, curIdx = -1, dragging = false, wasPlaying = false, first = true;\n    const loop = () => root.dataset.vsBarRaceLoop !== 'false';\n    const ticks = new Map();\n    const rankEls = [];\n\n    const readCfg = () => {\n      speed = clamp(cssNum(root, '--vs-bar-race-speed', .9), .05, 20);\n      holdFor = cssNum(root, '--vs-bar-race-hold', 1.2);\n    };\n    const layout = () => {\n      N = clamp(parseInt(root.dataset.vsBarRaceBars || '8', 10) || 8, 1, S);\n      W = plot.clientWidth; plotH = plot.clientHeight;\n      gutter = root.getBoundingClientRect().width < 560 ? Math.min(baseGutter, Math.max(104, W * .32)) : baseGutter;\n      plot.style.setProperty('--vs-bar-race-gutter', gutter + 'px');\n      trackW = Math.max(40, W - gutter - 58);\n      rowH = plotH / N;\n      const bh = Math.max(8, Math.round(rowH * .7));\n      for (const r of rows) {\n        r.r.style.height = rowH + 'px';\n        r.bar.style.cssText = `width:${trackW}px;height:${bh}px;top:${(rowH - bh) / 2}px`;\n        r.sh.style.cssText = `width:${trackW}px;height:${bh}px;top:${(rowH - bh) / 2}px;opacity:${r.sh.style.opacity || 0}`;\n      }\n      while (rankEls.length < S) rankEls.push(el('span', null, rankCol));\n      rankEls.forEach((e, i) => { e.textContent = String(i + 1).padStart(2, '0'); e.style.top = (i + .5) * rowH + 'px'; e.style.opacity = i < N ? '' : 0; });\n      kick();\n    };\n\n    const setPlaying = (p) => {\n      playing = p; root.toggleAttribute('data-playing', p);\n      btn.setAttribute('aria-label', p ? 'Pause' : 'Play');\n      if (p) { if (t >= P - 1 - 1e-3) { mode = 'rewind'; rwFrom = t; rwE = 0; } else mode = 'play'; }\n      else if (mode !== 'drag') { mode = 'idle'; tGoal = t; }\n      kick();\n    };\n    const announce = (() => { let to = 0; return () => { clearTimeout(to); to = setTimeout(() => {\n      if (playing) return;\n      const i = Math.round(t), top = series.map((s) => [s, s.y[i]]).sort((a, b) => b[1] - a[1]).slice(0, 3);\n      live.textContent = `${periods[i]}: ` + top.map(([s, v], k) => `${k + 1}. ${s.name} ${fmt.format(v)}`).join(', ');\n    }, 450); }; })();\n\n    // ── frame ──\n    const frame = (now) => {\n      raf = 0;\n      let dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      if ((cfgT -= dt) <= 0) { readCfg(); cfgT = .25; }\n      let moving = false;\n      // timeline\n      if (mode === 'play') {\n        t += dt * speed; tv = speed; tGoal = t;\n        if (t >= P - 1) { t = tGoal = P - 1; tv = 0; if (loop()) { mode = 'hold'; holdT = 0; } else setPlaying(false); }\n        moving = true;\n      } else if (mode === 'hold') {\n        holdT += dt; moving = true;\n        if (holdT >= holdFor) { mode = 'rewind'; rwFrom = t; rwE = 0; }\n      } else if (mode === 'rewind') {\n        // smooth reset: an ease-in-out run back to the first period (~1 s), then play again\n        rwE = Math.min(1, rwE + dt / Math.min(1.1, .35 + rwFrom * .07));\n        const e = rwE < .5 ? 8 * rwE ** 4 : 1 - Math.pow(-2 * rwE + 2, 4) / 2;\n        t = tGoal = rwFrom * (1 - e); tv = 0; moving = true;\n        if (rwE >= 1) { t = tGoal = 0; mode = 'play'; root.dispatchEvent(new CustomEvent('vs-bar-race:loop', { bubbles: true })); }\n      } else {\n        const k = 230, c = 2 * Math.sqrt(230) * .9;\n        for (let h = dt; h > 1e-6; h -= 1 / 240) {\n          const s = Math.min(h, 1 / 240);\n          if (reduced) { t = tGoal; tv = 0; break; }\n          tv += (k * (tGoal - t) - c * tv) * s; t += tv * s;\n        }\n        if (Math.abs(tGoal - t) > 1e-4 || Math.abs(tv) > 1e-3) moving = true; else { t = tGoal; tv = 0; }\n      }\n      t = clamp(t, 0, P - 1);\n\n      // values, ranks, scale\n      for (const r of rows) r.v = hermite(r.s.y, r.s.m, t);\n      const order = rows.slice().sort((a, b) => b.v - a.v);\n      order.forEach((r, i) => (r.rank = i));\n      const max = Math.max(order[0].v, 1e-6);\n      const x = (v) => v / max * trackW;\n\n      // rows: y on a spring; bars scale with the value\n      for (const r of rows) {\n        const goal = Math.min(r.rank, N) * rowH;\n        if (first || reduced) { r.y = goal; r.vy = 0; }\n        else for (let h = dt; h > 1e-6; h -= 1 / 240) {\n          const s = Math.min(h, 1 / 240);\n          r.vy += (200 * (goal - r.y) - 23 * r.vy) * s; r.y += r.vy * s;\n        }\n        if (Math.abs(goal - r.y) > .05 || Math.abs(r.vy) > .5) moving = true; else { r.y = goal; r.vy = 0; }\n        const opGoal = r.rank < N ? 1 : 0;\n        r.op += (opGoal - r.op) * (first || reduced ? 1 : 1 - Math.exp(-dt * 9));\n        if (Math.abs(opGoal - r.op) > .005) moving = true; else r.op = opGoal;\n        const lift = clamp(-r.vy / (rowH * 5), 0, 1);\n        const f = clamp(r.v / max, 0, 1);\n        r.r.style.transform = `translate3d(0,${r.y.toFixed(2)}px,0) scale(${(1 + lift * .03).toFixed(4)})`;\n        r.r.style.opacity = r.op.toFixed(3);\n        r.r.style.zIndex = lift > .02 ? 3 : r.rank < N ? 2 : 1;\n        r.fill.style.transform = `translate3d(${((f - 1) * trackW).toFixed(2)}px,0,0)`;\n        r.sh.style.transform = `translate3d(${((f - 1) * trackW).toFixed(2)}px,0,0)`;\n        r.sh.style.clipPath = `inset(-40px -40px -40px ${((1 - f) * trackW).toFixed(1)}px)`;\n        r.sh.style.opacity = lift.toFixed(3);\n        const pos = clamp(r.y / Math.max(1, rowH * (N - 1)), 0, 1);\n        r.ink.style.opacity = (.88 - pos * .6).toFixed(3);\n        r.lead.style.opacity = clamp(1 - r.y / (rowH * .9), 0, 1).toFixed(3);\n        r.val.style.transform = `translate3d(${(gutter + x(r.v) + 10).toFixed(2)}px,0,0)`;\n        const txt = fmt.format(r.v); if (txt !== r.txt) { r.val.textContent = txt; r.txt = txt; }\n      }\n\n      // grid ticks: a nice step for ~5 lines; lines that leave fade out\n      const step = niceStep(max, W < 500 ? 4 : 5), want = new Set();\n      for (let v = 0; v <= max + 1e-9; v += step) want.add(+v.toFixed(6));\n      for (const v of want) if (!ticks.has(v)) {\n        const e = el('div', 'vs-bar-race-tick' + (v === 0 ? ' is-zero' : ''), axis, `<span>${tickFmt.format(v)}</span>`);\n        ticks.set(v, { e, op: first ? 1 : 0 });\n      }\n      for (const [v, k] of ticks) {\n        const g = want.has(v) ? 1 : 0;\n        k.op += (g - k.op) * (reduced ? 1 : 1 - Math.exp(-dt * 7));\n        if (Math.abs(g - k.op) > .01) moving = true; else k.op = g;\n        const px = gutter + x(v);\n        if (!g && (k.op <= .01 || px > W + 40)) { k.e.remove(); ticks.delete(v); continue; }\n        k.e.style.transform = `translate3d(${px.toFixed(2)}px,0,0)`;\n        k.e.style.opacity = (k.op * (px > gutter + trackW + 30 ? 0 : 1)).toFixed(3);\n      }\n\n      // period counter: odometer digits, shortest way round\n      const idx = Math.floor(t + 1e-6);\n      if (idx !== curIdx) {\n        const str = String(periods[idx]).padStart(digits, '0');\n        dcols.forEach((c, i) => {\n          const d = +str[i]; if (isNaN(d)) return;\n          if (first || reduced) { c.p = c.goal = d; c.v = 0; return; }\n          const cur = ((Math.round(c.goal) % 10) + 10) % 10;\n          c.goal += ((d - cur) + 15) % 10 - 5;\n        });\n        if (curIdx !== -1) root.dispatchEvent(new CustomEvent('vs-bar-race:period', { bubbles: true, detail: { period: periods[idx], index: idx } }));\n        curIdx = idx;\n        scrub.setAttribute('aria-valuenow', periods[idx]); scrub.setAttribute('aria-valuetext', String(periods[idx]));\n        announce();\n      }\n      dcols.forEach((c, i) => {\n        if (!reduced && !first) for (let h = dt; h > 1e-6; h -= 1 / 240) {\n          const s = Math.min(h, 1 / 240);\n          c.v += (260 - i * 30) * (c.goal - c.p) * s - (22 - i) * c.v * s; c.p += c.v * s;\n        } else c.p = c.goal;\n        if (Math.abs(c.goal - c.p) > .002 || Math.abs(c.v) > .01) moving = true; else { c.p = c.goal; c.v = 0; }\n        const off = c.p - 10 * Math.floor(c.p / 10);\n        c.strip.style.transform = `translate3d(0,${(-off).toFixed(4)}em,0)`;\n      });\n\n      // scrubber\n      const k = t / (P - 1);\n      railFill.style.transform = `scaleX(${k.toFixed(4)})`;\n      knob.style.transform = `translate3d(${(k * scrub.clientWidth).toFixed(2)}px,0,0)`;\n\n      first = false;\n      if ((moving || playing) && visible) raf = requestAnimationFrame(frame);\n    };\n    const kick = () => { if (!raf && visible) { last = 0; raf = requestAnimationFrame(frame); } };\n\n    // ── input ──\n    btn.addEventListener('click', () => setPlaying(!playing));\n    const tAt = (cx) => { const b = scrub.getBoundingClientRect(); return clamp((cx - b.left) / b.width, 0, 1) * (P - 1); };\n    scrub.addEventListener('pointerdown', (e) => {\n      if (e.button !== 0) return;\n      dragging = true; wasPlaying = playing; if (playing) { playing = false; root.removeAttribute('data-playing'); }\n      mode = 'drag'; tGoal = tAt(e.clientX); scrub.classList.add('is-drag');\n      scrub.setPointerCapture(e.pointerId); kick(); e.preventDefault(); scrub.focus({ preventScroll: true });\n    });\n    scrub.addEventListener('pointermove', (e) => { if (dragging) { tGoal = tAt(e.clientX); kick(); } });\n    const end = () => { if (!dragging) return; dragging = false; scrub.classList.remove('is-drag'); mode = 'idle'; if (wasPlaying) setPlaying(true); else { tGoal = tGoal; kick(); } };\n    scrub.addEventListener('pointerup', end); scrub.addEventListener('pointercancel', end);\n    const seek = (i) => { if (playing) setPlaying(false); mode = 'idle'; tGoal = clamp(i, 0, P - 1); kick(); };\n    scrub.addEventListener('keydown', (e) => {\n      const cur = Math.round(tGoal);\n      const m = { ArrowRight: cur + 1, ArrowUp: cur + 1, ArrowLeft: cur - 1, ArrowDown: cur - 1, Home: 0, End: P - 1, PageUp: cur + 3, PageDown: cur - 3 }[e.key];\n      if (m !== undefined) { e.preventDefault(); seek(m); }\n    });\n    root.addEventListener('keydown', (e) => {\n      if ((e.key === ' ' || e.key === 'Spacebar') && e.target !== btn) { e.preventDefault(); setPlaying(!playing); }\n    });\n\n    // hover a bar: it stays lit and keeps its highlight while it moves\n    let hot = null;\n    const setHot = (r) => { if (hot === r) return; if (hot) hot.r.classList.remove('is-hot'); hot = r; if (r) r.r.classList.add('is-hot'); root.classList.toggle('is-hover', !!r); };\n    plot.addEventListener('pointermove', (e) => {\n      if (e.pointerType === 'touch') return;\n      const y = e.clientY - plot.getBoundingClientRect().top;\n      setHot(rows.find((r) => r.rank < N && y >= r.y && y < r.y + rowH) || null);\n    });\n    plot.addEventListener('pointerleave', () => setHot(null));\n\n    // ── observers ──\n    new ResizeObserver(layout).observe(root);\n    new MutationObserver(layout).observe(root, { attributes: true, attributeFilter: ['data-vs-bar-race-bars'] });\n    new IntersectionObserver(([en]) => {\n      visible = en.isIntersecting;\n      if (visible && !started) { started = true; if (wantPlay) setTimeout(() => { if (!playing && mode === 'idle') setPlaying(true); }, 500); }\n      if (visible) kick(); else if (raf) { cancelAnimationFrame(raf); raf = 0; }\n    }, { threshold: .2 }).observe(root);\n    readCfg(); layout();\n\n    return (root.vsBarRace = {\n      play: () => setPlaying(true), pause: () => setPlaying(false), toggle: () => setPlaying(!playing),\n      seek: (v) => seek(v >= periods[0] && periods.includes(v) ? periods.indexOf(v) : v),\n      get index() { return t; }, get playing() { return playing; }\n    });\n  }\n  window.VsBarRace = { mount };\n  const init = () => document.querySelectorAll('.vs-bar-race').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "bar chart race",
      "chart",
      "ranking",
      "animated chart",
      "data viz",
      "timeline",
      "scrubber",
      "spring",
      "dashboard"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Oswald:wght@500;700&family=Space+Grotesk:wght@400;500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#ff5a1f",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/bar-race/index.html",
    "thumbnail": "https://vividsites.app/api/component/bar-race/thumb.jpg",
    "video": "https://vividsites.app/api/component/bar-race/thumb.mp4",
    "page": "https://vividsites.app/c/bar-race"
  }
}