{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ticker",
  "type": "registry:block",
  "title": "KPI Ticker",
  "description": "A live KPI tape in stacked lanes where changed digits roll on their own springs and each tick flashes its cell green or red by whether the move is good for that metric. Hover glides a lane to a stop, and a drag flings it with inertia before it picks up its speed again.",
  "docs": "KPI Ticker was added to public/vividsites/ticker.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/ticker",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/ticker.html",
      "type": "registry:file",
      "target": "public/vividsites/ticker.html",
      "content": "<!--\n  vs-ticker: a live KPI tape. Two or three lanes scroll at their own speeds (a lane can run the other\n  way), each item with a label, a value whose changed digits roll on per-digit springs, a delta vs\n  the open whose arrow flips on a spring when the trend crosses zero, and an inline sparkline of its last 20 values. Values\n  random-walk every 0.8–2 s; each tick rolls the changed digits and flashes the cell green or red by\n  whether the move is GOOD for that metric (data-good=\"down\": latency rising flashes red). The delta,\n  its arrow and the sparkline carry the persistent trend vs the open, coloured the same way. Hover a lane and it glides to a stop\n  (velocity eases to 0 on a spring); drag or swipe it and it coasts with inertia, then hands back\n  to the auto speed. Pauses off-screen and in background tabs.\n\n  Markup:\n    <div class=\"vs-ticker\" data-vs-ticker-dir=\"left\">\n      <div class=\"vs-ticker-lane\" data-size=\"lg\" data-speed=\"46\" aria-label=\"Growth\">\n        <ul class=\"vs-ticker-list\">\n          <li data-value=\"1284\" data-change=\"2.1\" data-good=\"up\">Signups/hr</li>\n          <li data-value=\"184\" data-suffix=\"ms\" data-change=\"3.2\" data-good=\"down\">p95 latency</li>\n          …\n        </ul>\n      </div>\n      <div class=\"vs-ticker-lane\" data-size=\"md\" data-speed=\"30\" data-reverse>…</div>\n    </div>\n\n  Root attributes:\n    data-vs-ticker-dir       left | right: direction of travel (lanes with data-reverse run the other way)\n    data-vs-ticker-theme     dark | light                                    (dark)\n    data-vs-ticker-palette   classic (green/red) | teal (teal/orange) | cb (blue/amber, colour-blind safe)\n  Lane attributes: data-size lg | md | sm, data-speed px/s (40), data-reverse.\n  Item attributes (text = label):\n    data-value               starting value                data-decimals   fixed decimals (0)\n    data-prefix / -suffix    \"$\", \"ms\", \"%\", \"M\" …         data-change     % vs the open at start (0)\n    data-good                up | down                     data-vol        size of a tick, 0.004 = 0.4%\n    data-locale              Intl locale (en-US)           data-live=\"off\" never updates\n\n  Variables:\n    --vs-ticker-speed        speed multiplier, live       (1)\n    --vs-ticker-up / -down   good / bad colours (set by the palette; override freely)\n    --vs-ticker-ink / -mute / -line / -bg   text, muted text, hairlines, lane background\n    --vs-ticker-surface      the strip's own backdrop (#07080a dark, #f3f4f5 light; transparent to show yours)\n    --vs-ticker-lg / -md / -sm   value sizes per lane size\n    --vs-ticker-font         font family (inherit; a mono face with tabular figures is ideal)\n\n  API: root.vsTicker.set(label, value) pushes a value (e.g. from your socket); .pause() / .play().\n  Events: \"vs-ticker:tick\" on the root, detail {label, value, prev, good}.\n  Keyboard: each lane is focusable; focusing pauses it, Space toggles pause, ←/→ nudge.\n  Screen readers get the first copy of each list (clones are aria-hidden) and no live chatter.\n  prefers-reduced-motion: no scrolling; items wrap into a static grid and values update without flashes.\n-->\n<style>\n  .vs-ticker {\n    --vs-ticker-speed: 1;\n    --vs-ticker-up: #3ddc84;\n    --vs-ticker-down: #ff4d5e;\n    --vs-ticker-ink: #e8ecef;\n    --vs-ticker-mute: rgba(232, 236, 239, .46);\n    --vs-ticker-line: rgba(232, 236, 239, .09);\n    --vs-ticker-bg: transparent;\n    --vs-ticker-surface: #07080a;\n    --vs-ticker-lg: clamp(40px, 7.4vw, 98px);\n    --vs-ticker-md: clamp(22px, 3.1vw, 40px);\n    --vs-ticker-sm: 16px;\n    --vs-ticker-font: inherit;\n    display: grid; gap: 14px; position: relative; padding: 14px 0; background: var(--vs-ticker-surface);\n    font-family: var(--vs-ticker-font); color: var(--vs-ticker-ink);\n    font-variant-numeric: tabular-nums; font-feature-settings: \"tnum\" 1, \"zero\" 0;\n    -webkit-font-smoothing: antialiased;\n    -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);\n            mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);\n  }\n  .vs-ticker[data-vs-ticker-palette=\"teal\"] { --vs-ticker-up: #2ee6c8; --vs-ticker-down: #ff8a3d; }\n  .vs-ticker[data-vs-ticker-palette=\"cb\"] { --vs-ticker-up: #4da3ff; --vs-ticker-down: #ffb020; }\n  .vs-ticker[data-vs-ticker-theme=\"light\"] {\n    --vs-ticker-up: #0e9f55; --vs-ticker-down: #e0293d;\n    --vs-ticker-surface: #f3f4f5;\n    --vs-ticker-ink: #121417; --vs-ticker-mute: rgba(18, 20, 23, .5); --vs-ticker-line: rgba(18, 20, 23, .11);\n  }\n  .vs-ticker[data-vs-ticker-theme=\"light\"][data-vs-ticker-palette=\"teal\"] { --vs-ticker-up: #0a9e8a; --vs-ticker-down: #e0691c; }\n  .vs-ticker[data-vs-ticker-theme=\"light\"][data-vs-ticker-palette=\"cb\"] { --vs-ticker-up: #1f6fd6; --vs-ticker-down: #c98200; }\n\n  .vs-ticker-lane {\n    position: relative; overflow: hidden; background: var(--vs-ticker-bg);\n    border-block: .5px solid var(--vs-ticker-line);\n    cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; outline: none;\n  }\n  .vs-ticker-lane.is-drag { cursor: grabbing; }\n  .vs-ticker-lane:focus-visible { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--vs-ticker-ink) 55%, transparent); }\n  .vs-ticker-track { display: flex; width: max-content; will-change: transform; }\n  .vs-ticker-list { display: flex; margin: 0; padding: 0; list-style: none; flex: none; }\n\n  .vs-ticker-item {\n    position: relative; flex: none; display: flex; align-items: center; gap: 12px;\n    padding: 12px 22px; white-space: nowrap; line-height: 1;\n    box-shadow: inset -.5px 0 0 var(--vs-ticker-line);\n  }\n  .vs-ticker-flash {\n    position: absolute; inset: 0; pointer-events: none; opacity: 0;\n    background: linear-gradient(180deg, color-mix(in srgb, var(--c) 4%, transparent), color-mix(in srgb, var(--c) 20%, transparent));\n    box-shadow: inset 0 -2px 0 var(--c);\n    transition: opacity .7s cubic-bezier(.25, .6, .3, 1);\n  }\n  .vs-ticker-item[data-tick=\"good\"] { --c: var(--vs-ticker-up); }\n  .vs-ticker-item[data-tick=\"bad\"] { --c: var(--vs-ticker-down); }\n  @media (hover: hover) {\n    .vs-ticker-item { transition: background-color .25s; }\n    .vs-ticker-item:hover { background-color: color-mix(in srgb, var(--vs-ticker-ink) 3.5%, transparent); }\n    .vs-ticker-item:hover .vs-ticker-name { color: var(--vs-ticker-ink); }\n  }\n  .vs-ticker-name { transition: color .25s; }\n  .vs-ticker-item.is-hot .vs-ticker-flash { opacity: 1; transition: none; }\n  .vs-ticker-name { font-size: 11px; font-weight: 500; letter-spacing: .1em; text-transform: uppercase; color: var(--vs-ticker-mute); }\n  .vs-ticker-val { display: inline-flex; font-weight: 400; letter-spacing: -.03em; transition: color .9s cubic-bezier(.25, .6, .3, 1); }\n  .vs-ticker-item.is-hot .vs-ticker-val { color: var(--c); transition: none; }\n  .vs-ticker-chg { display: inline-flex; align-items: center; gap: 3px; font-size: 12px; font-weight: 500; letter-spacing: -.01em; color: var(--vs-ticker-mute); transition: color .4s; }\n  .vs-ticker-item[data-net=\"good\"] .vs-ticker-chg { color: var(--vs-ticker-up); }\n  .vs-ticker-item[data-net=\"bad\"] .vs-ticker-chg { color: var(--vs-ticker-down); }\n  .vs-ticker-fig { display: inline-flex; align-items: flex-start; gap: .12em; }\n  .vs-ticker-arrow { width: .9em; height: .9em; flex: none; display: block; }\n  .vs-ticker-spark { width: 56px; height: 18px; flex: none; overflow: visible; }\n  .vs-ticker-spark polyline { fill: none; stroke: var(--vs-ticker-mute); stroke-width: 1.25; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; transition: stroke .4s; }\n  .vs-ticker-item[data-net=\"good\"] .vs-ticker-spark polyline { stroke: var(--vs-ticker-up); }\n  .vs-ticker-item[data-net=\"bad\"] .vs-ticker-spark polyline { stroke: var(--vs-ticker-down); }\n\n  /* digit wheels: each changed digit rolls on its own spring */\n  .vs-ticker-col { position: relative; display: inline-block; height: 1.1em; overflow: hidden; margin: -.05em 0;\n    -webkit-mask-image: linear-gradient(180deg, transparent, #000 16%, #000 84%, transparent); mask-image: linear-gradient(180deg, transparent, #000 16%, #000 84%, transparent); }\n  .vs-ticker-strip { display: flex; flex-direction: column; }\n  .vs-ticker-strip > span, .vs-ticker-sep { display: block; height: 1.1em; line-height: 1.1em; text-align: center; }\n  .vs-ticker-sep { display: inline-block; }\n\n  /* lane sizes */\n  .vs-ticker-lane[data-size=\"lg\"] .vs-ticker-item {\n    display: grid; grid-template-columns: 1fr auto; grid-template-areas: \"name spark\" \"val val\" \"chg chg\";\n    column-gap: 28px; row-gap: 14px; padding: 30px 40px 28px; align-items: start;\n  }\n  .vs-ticker-lane[data-size=\"lg\"] .vs-ticker-name { grid-area: name; font-size: 12px; align-self: center; }\n  .vs-ticker-lane[data-size=\"lg\"] .vs-ticker-spark { grid-area: spark; width: 76px; height: 22px; }\n  .vs-ticker-lane[data-size=\"lg\"] .vs-ticker-fig { grid-area: val; font-size: var(--vs-ticker-lg); margin: 2px 0 0 -.04em; }\n  .vs-ticker-lane[data-size=\"lg\"] .vs-ticker-val { letter-spacing: -.05em; }\n  .vs-ticker-lane[data-size=\"lg\"] .vs-ticker-chg { grid-area: chg; font-size: 14px; }\n  .vs-ticker-lane[data-size=\"lg\"] .vs-ticker-chg::after { content: \"vs open\"; margin-left: 10px; font-weight: 400; letter-spacing: .02em; color: var(--vs-ticker-mute); }\n  .vs-ticker-lane[data-size=\"md\"] .vs-ticker-item { padding: 22px 30px; gap: 16px; }\n  .vs-ticker-lane[data-size=\"md\"] .vs-ticker-fig { font-size: var(--vs-ticker-md); }\n  .vs-ticker-lane[data-size=\"md\"] .vs-ticker-val { letter-spacing: -.04em; }\n  .vs-ticker-lane[data-size=\"md\"] .vs-ticker-chg { font-size: 13px; }\n  .vs-ticker-lane[data-size=\"sm\"] .vs-ticker-item { padding: 13px 22px; gap: 10px; }\n  .vs-ticker-lane[data-size=\"sm\"] .vs-ticker-fig { font-size: var(--vs-ticker-sm); }\n  .vs-ticker-lane[data-size=\"sm\"] .vs-ticker-name { font-size: 10.5px; }\n  .vs-ticker-lane[data-size=\"sm\"] .vs-ticker-spark { width: 40px; height: 14px; }\n\n  /* reduced motion: a static grid */\n  .vs-ticker.is-static { -webkit-mask-image: none; mask-image: none; }\n  .vs-ticker.is-static .vs-ticker-lane { cursor: default; }\n  .vs-ticker.is-static .vs-ticker-track { width: auto; transform: none !important; }\n  .vs-ticker.is-static .vs-ticker-list { flex-wrap: wrap; flex: 1; }\n  .vs-ticker.is-static .vs-ticker-list[aria-hidden] { display: none; }\n</style>\n\n<div class=\"vs-ticker\" data-vs-ticker-dir=\"left\">\n  <div class=\"vs-ticker-lane\" data-size=\"lg\" data-speed=\"46\" aria-label=\"Growth metrics\">\n    <ul class=\"vs-ticker-list\">\n      <li data-value=\"1284\" data-change=\"2.1\" data-good=\"up\" data-vol=\"0.006\">Signups/hr</li>\n      <li data-value=\"1.92\" data-decimals=\"2\" data-prefix=\"$\" data-suffix=\"M\" data-change=\"0.8\" data-good=\"up\" data-vol=\"0.003\">MRR</li>\n      <li data-value=\"184\" data-suffix=\"ms\" data-change=\"3.2\" data-good=\"down\" data-vol=\"0.02\">p95 latency</li>\n      <li data-value=\"0.21\" data-decimals=\"2\" data-suffix=\"%\" data-change=\"-4.5\" data-good=\"down\" data-vol=\"0.03\">Error rate</li>\n      <li data-value=\"9406\" data-change=\"1.4\" data-good=\"up\" data-vol=\"0.004\">Active now</li>\n      <li data-value=\"3118\" data-change=\"0.6\" data-good=\"up\" data-vol=\"0.003\">Orders</li>\n      <li data-value=\"3.84\" data-decimals=\"2\" data-suffix=\"%\" data-change=\"0.3\" data-good=\"up\" data-vol=\"0.006\">Conv. rate</li>\n      <li data-value=\"12\" data-change=\"-8\" data-good=\"down\" data-vol=\"0.06\">Refunds</li>\n    </ul>\n  </div>\n  <div class=\"vs-ticker-lane\" data-size=\"md\" data-speed=\"30\" data-reverse aria-label=\"Product metrics\">\n    <ul class=\"vs-ticker-list\">\n      <li data-value=\"1.82\" data-decimals=\"2\" data-suffix=\"%\" data-change=\"-0.4\" data-good=\"down\" data-vol=\"0.006\">Churn 30d</li>\n      <li data-value=\"62\" data-change=\"1.6\" data-good=\"up\" data-vol=\"0.012\">NPS</li>\n      <li data-value=\"148\" data-change=\"5.7\" data-good=\"down\" data-vol=\"0.02\">Tickets open</li>\n      <li data-value=\"64.20\" data-decimals=\"2\" data-prefix=\"$\" data-change=\"1.2\" data-good=\"up\" data-vol=\"0.004\">Avg. basket</li>\n      <li data-value=\"27\" data-change=\"12.5\" data-good=\"up\" data-vol=\"0.03\">Deploys today</li>\n      <li data-value=\"99.982\" data-decimals=\"3\" data-suffix=\"%\" data-change=\"0.01\" data-good=\"up\" data-vol=\"0.00004\">Uptime</li>\n      <li data-value=\"312\" data-change=\"-6.3\" data-good=\"down\" data-vol=\"0.03\">Queue depth</li>\n      <li data-value=\"21.7\" data-decimals=\"1\" data-suffix=\"%\" data-change=\"0.9\" data-good=\"up\" data-vol=\"0.006\">Trial to paid</li>\n    </ul>\n  </div>\n  <div class=\"vs-ticker-lane\" data-size=\"sm\" data-speed=\"20\" aria-label=\"Infrastructure metrics\">\n    <ul class=\"vs-ticker-list\">\n      <li data-value=\"92\" data-suffix=\"ms\" data-change=\"-2.1\" data-good=\"down\" data-vol=\"0.02\">eu-west</li>\n      <li data-value=\"71\" data-suffix=\"ms\" data-change=\"1.4\" data-good=\"down\" data-vol=\"0.02\">us-east</li>\n      <li data-value=\"168\" data-suffix=\"ms\" data-change=\"4.9\" data-good=\"down\" data-vol=\"0.02\">ap-south</li>\n      <li data-value=\"94.1\" data-decimals=\"1\" data-suffix=\"%\" data-change=\"0.2\" data-good=\"up\" data-vol=\"0.002\">Cache hit</li>\n      <li data-value=\"2840\" data-change=\"3.3\" data-good=\"up\" data-vol=\"0.008\">Webhooks/min</li>\n      <li data-value=\"1902\" data-change=\"-1.1\" data-good=\"up\" data-vol=\"0.008\">API calls/s</li>\n      <li data-value=\"61\" data-suffix=\"%\" data-change=\"0.8\" data-good=\"down\" data-vol=\"0.01\">Disk</li>\n      <li data-value=\"22915\" data-change=\"2.4\" data-good=\"up\" data-vol=\"0.003\">Sessions</li>\n      <li data-value=\"41\" data-change=\"-3.2\" data-good=\"down\" data-vol=\"0.03\">Invoices due</li>\n    </ul>\n  </div>\n</div>\n\n<script>\n(() => {\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const NS = 'http://www.w3.org/2000/svg';\n  const ARROW = '<svg class=\"vs-ticker-arrow\" viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M8 3.2 13 10.4H3Z\" fill=\"currentColor\"/></svg>';\n  const HIST = 20;\n  const gauss = () => { let u = 0; while (!u) u = Math.random(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * Math.random()); };\n  const spring = (s, k, c, dt) => { for (let h = 0; h < dt; h += 1 / 120) { const t = Math.min(1 / 120, dt - h); s.v += (-k * (s.x - s.g) - c * s.v) * t; s.x += s.v * t; } if (Math.abs(s.x - s.g) < 1e-3 && Math.abs(s.v) < 1e-2) { s.x = s.g; s.v = 0; return false; } return true; };\n\n  function makeItem(li) {\n    const d = li.dataset;\n    const dec = parseInt(d.decimals || '0', 10);\n    const nf = new Intl.NumberFormat(d.locale || 'en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec });\n    const value = parseFloat(d.value) || 0;\n    const base = value / (1 + (parseFloat(d.change) || 0) / 100);\n    const m = {\n      label: li.textContent.trim(), dec, good: d.good === 'down' ? 'down' : 'up', vol: parseFloat(d.vol) || .004,\n      live: d.live !== 'off', value, base, anchor: value, hist: [], insts: [], cols: [], pattern: '',\n      rot: { x: 0, v: 0, g: 0 }, nextAt: 0, busy: false, hotT: 0,\n      fmt: (v) => (d.prefix || '') + nf.format(v) + (d.suffix || ''),\n    };\n    // a plausible recent history, ending at the current value\n    let v = base; for (let i = 0; i < HIST - 1; i++) { v += (value - v) * .18 + gauss() * m.vol * value * 1.1; m.hist.push(v); }\n    m.hist.push(value);\n    li.classList.add('vs-ticker-item');\n    li.textContent = '';\n    li.innerHTML = `<span class=\"vs-ticker-flash\" aria-hidden=\"true\"></span><span class=\"vs-ticker-name\"></span><span class=\"vs-ticker-fig\"><span class=\"vs-ticker-val\"></span></span><span class=\"vs-ticker-chg\">${ARROW}<span></span></span><svg class=\"vs-ticker-spark\" viewBox=\"0 0 100 20\" preserveAspectRatio=\"none\" aria-hidden=\"true\"><polyline/></svg>`;\n    li.querySelector('.vs-ticker-name').textContent = m.label;\n    m.li = li;\n    return m;\n  }\n  const bindInst = (m, li) => {\n    const inst = { li, val: li.querySelector('.vs-ticker-val'), txt: li.querySelector('.vs-ticker-chg > span'), arrow: li.querySelector('.vs-ticker-arrow'), poly: li.querySelector('polyline') };\n    m.insts.push(inst); return inst;\n  };\n  const digitsOf = (s) => [...s].filter((c) => c >= '0' && c <= '9').map(Number);\n\n  // (re)build the wheels of every copy when the value's shape changes (1,999 → 2,000 keeps shape)\n  function layout(m) {\n    const str = m.fmt(m.value), pat = str.replace(/\\d/g, '0');\n    m.pattern = pat; m.cols = [];\n    const ds = digitsOf(str);\n    let di = 0;\n    for (const ch of str) if (ch >= '0' && ch <= '9') { m.cols.push({ x: ds[di], v: 0, g: ds[di], due: 0, strips: [] }); di++; }\n    m.insts.forEach((inst) => {\n      inst.val.textContent = '';\n      let k = 0;\n      for (const ch of str) {\n        if (ch >= '0' && ch <= '9') {\n          const col = document.createElement('span'); col.className = 'vs-ticker-col';\n          const strip = document.createElement('span'); strip.className = 'vs-ticker-strip';\n          strip.innerHTML = '<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span><span>0</span>';\n          col.appendChild(strip); inst.val.appendChild(col); m.cols[k++].strips.push(strip);\n        } else { const s = document.createElement('span'); s.className = 'vs-ticker-sep'; s.textContent = ch; inst.val.appendChild(s); }\n      }\n      inst.val.setAttribute('aria-label', str);\n    });\n    m.cols.forEach(paintCol);\n  }\n  const paintCol = (c) => { const q = ((c.x % 10) + 10) % 10; const t = `translate3d(0,${(-q * 1.1).toFixed(4)}em,0)`; c.strips.forEach((s) => (s.style.transform = t)); };\n\n  function paintMeta(m) {\n    const net = (m.value - m.base) / m.base * 100;\n    const r = Math.abs(net) < .005 ? 0 : net;\n    const txt = Math.abs(r).toFixed(Math.abs(r) < .1 ? 2 : 1) + '%';\n    m.rot.g = r < 0 ? 180 : r === 0 ? 90 : 0;     // the arrow shows the trend vs the open\n    const tone = r === 0 ? 'flat' : (r > 0) === (m.good === 'up') ? 'good' : 'bad';\n    const lo = Math.min(...m.hist), hi = Math.max(...m.hist), span = hi - lo || 1;\n    const pts = m.hist.map((v, i) => `${(i / (HIST - 1) * 100).toFixed(1)},${(18 - (v - lo) / span * 16).toFixed(2)}`).join(' ');\n    m.insts.forEach((inst) => { inst.txt.textContent = txt; inst.li.dataset.net = tone; inst.poly.setAttribute('points', pts); });\n  }\n\n  function setValue(m, next, now, animate) {\n    const prev = m.value;\n    if (next === prev) return;\n    m.value = next; m.hist.push(next); if (m.hist.length > HIST) m.hist.shift();\n    const str = m.fmt(next), dir = next > prev ? 1 : -1;\n    const tick = (dir > 0) === (m.good === 'up') ? 'good' : 'bad';\n    if (str.replace(/\\d/g, '0') !== m.pattern) layout(m);\n    else {\n      const ds = digitsOf(str), n = m.cols.length;\n      m.cols.forEach((c, i) => {\n        const cur = ((Math.round(c.g) % 10) + 10) % 10;\n        if (cur === ds[i]) return;\n        const steps = ((ds[i] - cur) * dir + 10) % 10;\n        c.g = Math.round(c.g) + steps * dir;\n        c.due = now + (n - 1 - i) * 32;       // carries ripple right to left\n        if (!animate) { c.x = c.g; c.v = 0; paintCol(c); }\n      });\n      m.insts.forEach((inst) => inst.val.setAttribute('aria-label', str));\n    }\n    m.insts.forEach((inst) => { inst.li.dataset.tick = tick; if (animate) inst.li.classList.add('is-hot'); });\n    if (animate) m.hotT = now + 70;\n    m.busy = animate;\n    paintMeta(m);\n    if (!animate) { m.rot.x = m.rot.g; m.insts.forEach((inst) => (inst.arrow.style.transform = `rotate(${m.rot.x}deg)`)); }\n    return { prev, tick };\n  }\n\n  function mount(root) {\n    if (root.vsTicker) return;\n    const items = [];\n    const lanes = [...root.querySelectorAll('.vs-ticker-lane')].map((lane) => {\n      const list = lane.querySelector('.vs-ticker-list');\n      const track = document.createElement('div'); track.className = 'vs-ticker-track';\n      lane.insertBefore(track, list); track.appendChild(list);\n      const models = [...list.children].map(makeItem);\n      models.forEach((m) => { bindInst(m, m.li); items.push(m); });\n      lane.tabIndex = 0; lane.setAttribute('role', 'marquee');\n      return { lane, track, list, models, clones: [], x: 0, v: 0, a: 0, setW: 1, hover: false, focus: false, paused: false, drag: null, coast: false,\n        speed: parseFloat(lane.dataset.speed) || 40, rev: lane.hasAttribute('data-reverse') };\n    });\n    items.forEach((m) => {\n      layout(m); paintMeta(m);\n      m.rot.x = m.rot.g;\n      m.insts[0].arrow.style.transform = `rotate(${m.rot.x}deg)`;\n    });\n\n    let mul = 1, dirSign = 1;\n    const readVars = () => { mul = parseFloat(getComputedStyle(root).getPropertyValue('--vs-ticker-speed')) || 0; dirSign = root.getAttribute('data-vs-ticker-dir') === 'right' ? -1 : 1; };\n    readVars();\n    new MutationObserver(readVars).observe(root, { attributes: true, attributeFilter: ['style', 'data-vs-ticker-dir'] });\n\n    // enough copies of each list to cover the lane twice, so the loop never shows a seam\n    const fill = (L) => {\n      if (reduced) return;\n      L.setW = L.list.getBoundingClientRect().width || 1;\n      const need = Math.max(1, Math.ceil(L.lane.clientWidth / L.setW) + 1);\n      while (L.clones.length < need) {\n        const c = L.list.cloneNode(true); c.setAttribute('aria-hidden', 'true');\n        [...c.children].forEach((li, i) => { li.classList.remove('is-hot'); const m = L.models[i]; const inst = bindInst(m, li); let k = 0; li.querySelectorAll('.vs-ticker-strip').forEach((s) => m.cols[k++]?.strips.push(s)); });\n        L.track.appendChild(c); L.clones.push(c);\n      }\n    };\n    const ro = new ResizeObserver(() => lanes.forEach(fill));\n    lanes.forEach((L) => { ro.observe(L.lane); ro.observe(L.list); });\n    if (reduced) root.classList.add('is-static');\n\n    // interaction: hover glides to a stop, drag scrubs, release coasts with inertia\n    lanes.forEach((L) => {\n      const el = L.lane;\n      el.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') L.hover = true; });\n      el.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') L.hover = false; });\n      el.addEventListener('pointerdown', (e) => {\n        if (reduced || e.button > 0) return;\n        L.drag = { id: e.pointerId, x0: e.clientX, off0: L.x, lx: e.clientX, lt: performance.now(), vel: 0, moved: false };\n      });\n      el.addEventListener('pointermove', (e) => {\n        const D = L.drag; if (!D || D.id !== e.pointerId) return;\n        if (!D.moved && Math.abs(e.clientX - D.x0) > 4) { D.moved = true; el.setPointerCapture(e.pointerId); el.classList.add('is-drag'); }\n        if (!D.moved) return;\n        const now = performance.now(), dt = Math.max(1, now - D.lt) / 1000;\n        const inst = -(e.clientX - D.lx) / dt;\n        D.vel = D.vel * .6 + inst * .4; D.lx = e.clientX; D.lt = now;\n        L.x = D.off0 - (e.clientX - D.x0);\n        kick();\n      });\n      const end = (e) => {\n        const D = L.drag; if (!D || D.id !== e.pointerId) return;\n        L.drag = null; el.classList.remove('is-drag');\n        if (D.moved) { const idle = performance.now() - D.lt > 90; L.v = idle ? 0 : Math.max(-4000, Math.min(4000, D.vel)); L.a = 0; L.coast = true; }\n        if (e.pointerType !== 'mouse') L.hover = false;\n      };\n      el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);\n      el.addEventListener('focus', () => { L.focus = true; });\n      el.addEventListener('blur', () => { L.focus = false; L.paused = false; });\n      el.addEventListener('keydown', (e) => {\n        if (e.key === ' ' || e.key === 'Enter') { L.paused = !L.paused; L.focus = L.paused; e.preventDefault(); }\n        else if (e.key === 'ArrowRight') { L.v += 700; L.coast = true; e.preventDefault(); }\n        else if (e.key === 'ArrowLeft') { L.v -= 700; L.coast = true; e.preventDefault(); }\n        kick();\n      });\n    });\n\n    let raf = 0, last = 0, visible = false, globalPause = false;\n    const tickItem = (m, now) => {\n      // mean-reverting random walk around a slowly drifting anchor\n      m.anchor += (m.base * (1 + (Math.random() - .45) * .06) - m.anchor) * .05;\n      let next = m.value + (m.anchor - m.value) * .08 + gauss() * m.vol * m.value;\n      const q = Math.pow(10, m.dec); next = Math.round(next * q) / q;\n      if (next === m.value) next = Math.round((m.value + (Math.random() < .5 ? -1 : 1) / q) * q) / q;\n      if (next <= 0) next = m.value;\n      const r = setValue(m, next, now, !reduced);\n      if (r) root.dispatchEvent(new CustomEvent('vs-ticker:tick', { detail: { label: m.label, value: next, prev: r.prev, good: r.tick === 'good' } }));\n    };\n    const schedule = (m, now) => (m.nextAt = now + 800 + Math.random() * 1200);\n    items.forEach((m) => (m.nextAt = performance.now() + 300 + Math.random() * 1600));\n\n    function frame(now) {\n      const dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      lanes.forEach((L) => {\n        if (L.drag && L.drag.moved) { L.v = L.drag.vel; L.a = 0; }\n        else {\n          const stop = L.hover || L.focus || L.paused || globalPause;\n          const target = stop ? 0 : L.speed * mul * dirSign * (L.rev ? -1 : 1);\n          // critically damped spring on the velocity: glides to a stop, coasts after a fling\n          const w = L.coast ? 1.7 : 4.2;\n          L.a += (w * w * (target - L.v) - 2 * w * L.a) * dt; L.v += L.a * dt;\n          if (L.coast && Math.abs(L.v - target) < 2) L.coast = false;\n          L.x += L.v * dt;\n        }\n        const W = L.setW;\n        const x = ((L.x % W) + W) % W;\n        L.track.style.transform = `translate3d(${(-x).toFixed(2)}px,0,0)`;\n      });\n      items.forEach((m) => {\n        if (m.live && now >= m.nextAt) { tickItem(m, now); schedule(m, now); }\n        if (m.hotT && now >= m.hotT) { m.hotT = 0; m.insts.forEach((i) => i.li.classList.remove('is-hot')); }\n        if (m.busy) {\n          let moving = false;\n          m.cols.forEach((c) => { if (now < c.due) { moving = true; return; } if (c.x !== c.g || c.v) { if (spring(c, 230, 21, dt)) moving = true; paintCol(c); } });\n          if (m.rot.x !== m.rot.g || m.rot.v) { if (spring(m.rot, 320, 16, dt)) moving = true; const t = `rotate(${m.rot.x.toFixed(2)}deg)`; m.insts.forEach((i) => (i.arrow.style.transform = t)); }\n          m.busy = moving;\n        }\n      });\n      raf = visible && !document.hidden ? requestAnimationFrame(frame) : 0;\n      if (!raf) last = 0;\n    }\n    const kick = () => { if (!raf && visible && !document.hidden && !reduced) raf = requestAnimationFrame(frame); };\n\n    // reduced motion: no rAF, values still update on a plain timer (no flashes, no rolls)\n    let timer = 0;\n    const slow = () => { const now = performance.now(); items.forEach((m) => { if (m.live && now >= m.nextAt) { tickItem(m, now); schedule(m, now); } }); };\n    new IntersectionObserver((es) => {\n      visible = es[0].isIntersecting;\n      if (reduced) { clearInterval(timer); if (visible) timer = setInterval(slow, 400); }\n      else kick();\n    }).observe(root);\n    document.addEventListener('visibilitychange', kick);\n\n    root.vsTicker = {\n      set: (label, value) => { const m = items.find((i) => i.label === label); if (m) { setValue(m, value, performance.now(), !reduced); kick(); } },\n      pause: () => { globalPause = true; }, play: () => { globalPause = false; kick(); },\n      lanes: () => lanes.map((L) => ({ x: L.x, v: L.v, width: L.setW })),\n    };\n  }\n  const init = () => document.querySelectorAll('.vs-ticker').forEach(mount);\n  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "ticker",
      "kpi",
      "live",
      "marquee",
      "dashboard",
      "metrics",
      "sparkline",
      "realtime",
      "spring"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#3ddc84",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/ticker/index.html",
    "thumbnail": "https://vividsites.app/api/component/ticker/thumb.jpg",
    "video": "https://vividsites.app/api/component/ticker/thumb.mp4",
    "page": "https://vividsites.app/c/ticker"
  }
}