{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tab-indicator",
  "type": "registry:block",
  "title": "Tab Indicator",
  "description": "Tabs with a spring-driven indicator in three variants: pill, underline and a stretch morph whose leading edge leaps ahead and trailing edge catches up, thinning as it stretches. Labels change colour exactly where the indicator crosses them, and panels crossfade in from the direction of travel.",
  "docs": "Tab Indicator was added to public/vividsites/tab-indicator.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/tab-indicator",
  "categories": [
    "ui",
    "navigation"
  ],
  "files": [
    {
      "path": "components/vividsites/tab-indicator.html",
      "type": "registry:file",
      "target": "public/vividsites/tab-indicator.html",
      "content": "<!--\nvs-tab-indicator: tabs whose indicator rides two springs, one per edge. Three variants:\n  pill (a filled capsule), underline (a lit bar) and stretch, where the leading edge leaps\n  ahead on a stiff spring and the trailing edge follows on a soft one, so the indicator\n  stretches toward the target, thins as it stretches (it keeps its area) and fattens back\n  as it contracts. Every spring keeps its velocity when you click again mid-flight. The\n  labels under the indicator are redrawn in the indicator's ink through the same clip, so\n  a letter changes colour exactly where the edge crosses it. A hover ghost glides between\n  tabs, pressing pushes the shape in, and panels crossfade in from the direction of travel.\n  ARIA tablist with roving tabindex: arrows, Home and End move and select.\n\n  Markup:  <div class=\"vs-tab-indicator\" data-variant=\"pill\">\n             <div class=\"vs-tab-indicator-list\" aria-label=\"Project\">\n               <button class=\"vs-tab-indicator-tab\" aria-selected=\"true\">Overview</button> ...\n             </div>\n             <div class=\"vs-tab-indicator-panels\">                 (optional)\n               <section class=\"vs-tab-indicator-panel\">...</section> ... one per tab, in order\n             </div>\n           </div>\n  Attributes:\n    data-variant     \"pill\" | \"underline\" | \"stretch\" (default pill)\n    data-stiffness   spring stiffness, 60 – 2000 (default 420); higher is snappier\n    aria-selected    \"true\" on the tab that starts active\n    aria-disabled    \"true\" on a tab: dimmed, skipped by the keyboard, not selectable\n  Surface: the root paints its own dark panel (--vs-tab-indicator-surface, --vs-tab-indicator-pad,\n    --vs-tab-indicator-radius) so it reads on any page. On a dark page you can drop it:\n    --vs-tab-indicator-surface: transparent; --vs-tab-indicator-pad: 0.\n  Modifier: .vs-tab-indicator--light for light pages (a light panel and dark ink).\n  Inside a tab: <span class=\"vs-tab-indicator-count\">12</span> for a count badge.\n  CSS variables: --vs-tab-indicator-accent, --vs-tab-indicator-on-accent, --vs-tab-indicator-ink,\n    --vs-tab-indicator-track, --vs-tab-indicator-line, --vs-tab-indicator-ghost,\n    --vs-tab-indicator-focus, --vs-tab-indicator-size (scales everything), --vs-tab-indicator-font,\n    --vs-tab-indicator-surface, --vs-tab-indicator-pad, --vs-tab-indicator-radius.\n  Events: \"change\" bubbles from .vs-tab-indicator, detail { index, tab }.\n  JS: vsTabIndicator.mount(el) for tabs added later; el.__vsTabs.select(i) selects from code.\n  Reduced motion: the indicator jumps, panels only fade.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-tab-indicator\" data-variant=\"pill\" data-stiffness=\"420\">\n  <div class=\"vs-tab-indicator-list\" aria-label=\"Project\">\n    <button class=\"vs-tab-indicator-tab\" aria-selected=\"true\">Overview</button>\n    <button class=\"vs-tab-indicator-tab\">Deploys</button>\n    <button class=\"vs-tab-indicator-tab\">Logs</button>\n    <button class=\"vs-tab-indicator-tab\">Usage</button>\n    <button class=\"vs-tab-indicator-tab\">Settings</button>\n  </div>\n  <div class=\"vs-tab-indicator-panels\">\n    <section class=\"vs-tab-indicator-panel\"><dl class=\"vs-tab-indicator-card\"><div><dt>Requests</dt><dd>1.28M<small><b>+4.2%</b></small></dd></div><div><dt>Latency</dt><dd>142<small>ms</small></dd></div><div><dt>Errors</dt><dd>0.03<small>%</small></dd></div></dl></section>\n    <section class=\"vs-tab-indicator-panel\"><dl class=\"vs-tab-indicator-card\"><div><dt>Latest</dt><dd>a7f3c2</dd></div><div><dt>Status</dt><dd><b>Ready</b></dd></div><div><dt>Built in</dt><dd>38<small>s</small></dd></div></dl></section>\n    <section class=\"vs-tab-indicator-panel\"><dl class=\"vs-tab-indicator-card\"><div><dt>Lines / min</dt><dd>2,410</dd></div><div><dt>Warnings</dt><dd>3</dd></div><div><dt>Retention</dt><dd>30<small>days</small></dd></div></dl></section>\n    <section class=\"vs-tab-indicator-panel\"><dl class=\"vs-tab-indicator-card\"><div><dt>Bandwidth</dt><dd>412<small>GB</small></dd></div><div><dt>Builds</dt><dd>1,840<small>min</small></dd></div><div><dt>Seats</dt><dd>6<small>of 10</small></dd></div></dl></section>\n    <section class=\"vs-tab-indicator-panel\"><dl class=\"vs-tab-indicator-card\"><div><dt>Region</dt><dd>fra1</dd></div><div><dt>Runtime</dt><dd>v22.4</dd></div><div><dt>Previews</dt><dd><b>On</b></dd></div></dl></section>\n  </div>\n</div>\n\n<style>\n.vs-tab-indicator {\n  --vs-tab-indicator-accent: #c8f55a;         /* the indicator */\n  --vs-tab-indicator-on-accent: #0b0d08;      /* label ink on the indicator (pill / stretch) */\n  --vs-tab-indicator-ink: #f2f1ec;            /* label ink; idle tabs use it at 50% */\n  --vs-tab-indicator-track: rgba(255,255,255,.045);\n  --vs-tab-indicator-line: rgba(255,255,255,.1);\n  --vs-tab-indicator-ghost: rgba(255,255,255,.075);  /* hover highlight */\n  --vs-tab-indicator-focus: var(--vs-tab-indicator-accent);\n  --vs-tab-indicator-size: 16px;              /* scales everything */\n  --vs-tab-indicator-font: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n  --vs-tab-indicator-surface: radial-gradient(120% 90% at 50% 0%, #1a1c20, #0c0d0f 70%);  /* the component's own panel */\n  --vs-tab-indicator-pad: 1.25em;             /* inner padding of that panel */\n  --vs-tab-indicator-radius: 1.4em;\n  display: grid; justify-items: start; gap: 1.1em; min-width: 0; max-width: 100%; width: fit-content;  /* hugs the tab bar */\n  padding: var(--vs-tab-indicator-pad); border-radius: var(--vs-tab-indicator-radius);\n  background: var(--vs-tab-indicator-surface);\n  box-shadow: 0 0 0 1px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.12), 0 18px 48px -18px rgba(0,0,0,.45);\n  font: 400 var(--vs-tab-indicator-size)/1 var(--vs-tab-indicator-font);\n  color: var(--vs-tab-indicator-ink); -webkit-font-smoothing: antialiased;\n}\n.vs-tab-indicator, .vs-tab-indicator * { box-sizing: border-box; }\n.vs-tab-indicator--light {                  /* for light pages */\n  --vs-tab-indicator-accent: #17181c; --vs-tab-indicator-on-accent: #f6f5f1; --vs-tab-indicator-ink: #17181c;\n  --vs-tab-indicator-track: rgba(23,24,28,.05); --vs-tab-indicator-line: rgba(23,24,28,.12); --vs-tab-indicator-ghost: rgba(23,24,28,.06);\n  --vs-tab-indicator-focus: #2f6bff;\n  --vs-tab-indicator-surface: linear-gradient(180deg, #ffffff, #f7f6f2);\n  box-shadow: 0 0 0 1px rgba(23,24,28,.07), 0 1px 2px rgba(23,24,28,.05), 0 18px 44px -22px rgba(23,24,28,.22);\n}\n.vs-tab-indicator--light .vs-tab-indicator-card { background: rgba(23,24,28,.02); }\n.vs-tab-indicator--light .vs-tab-indicator-list { box-shadow: inset 0 0 0 .5px var(--vs-tab-indicator-line), inset 0 1px 1px rgba(23,24,28,.04), 0 1px 0 rgba(255,255,255,.7); }\n.vs-tab-indicator--light .vs-tab-indicator-glow { opacity: .14; }\n.vs-tab-indicator-list {\n  position: relative; display: inline-flex; gap: .2em; padding: .3em; max-width: 100%;\n  border-radius: 999px; isolation: isolate; user-select: none; -webkit-user-select: none;\n  background: var(--vs-tab-indicator-track);\n  box-shadow: inset 0 0 0 .5px var(--vs-tab-indicator-line), inset 0 1px 0 rgba(255,255,255,.035), 0 1px 0 rgba(0,0,0,.4);\n}\n.vs-tab-indicator-tab {\n  all: unset; box-sizing: border-box; position: relative; z-index: 1; flex: none;\n  display: inline-flex; align-items: center; justify-content: center; gap: .55em;\n  height: 2.6em; padding: 0 1.15em; border-radius: 999px; cursor: pointer; white-space: nowrap;\n  font: inherit; letter-spacing: -.01em; -webkit-tap-highlight-color: transparent;\n  color: color-mix(in srgb, var(--vs-tab-indicator-ink) 48%, transparent);\n  transition: color .15s cubic-bezier(.2,0,0,1);\n}\n.vs-tab-indicator-tab:hover { color: color-mix(in srgb, var(--vs-tab-indicator-ink) 82%, transparent); }\n.vs-tab-indicator-tab[aria-disabled=\"true\"] { cursor: not-allowed; color: color-mix(in srgb, var(--vs-tab-indicator-ink) 22%, transparent); }\n.vs-tab-indicator-tab:focus-visible { outline: 1.5px solid var(--vs-tab-indicator-focus); outline-offset: 3px; }\n.vs-tab-indicator-count {\n  display: inline-grid; place-items: center; min-width: 1.6em; height: 1.35em; padding: 0 .4em; border-radius: 999px;\n  font-size: .72em; letter-spacing: 0; font-variant-numeric: tabular-nums;\n  background: color-mix(in srgb, currentColor 14%, transparent); box-shadow: inset 0 0 0 .5px color-mix(in srgb, currentColor 22%, transparent);\n}\n/* layers the script adds: a hover ghost, a soft glow, and the active layer (label clones on the indicator) */\n.vs-tab-indicator-ghost, .vs-tab-indicator-hi { position: absolute; inset: 0; pointer-events: none; border-radius: inherit; }\n.vs-tab-indicator-ghost { z-index: 0; background: var(--vs-tab-indicator-ghost); opacity: 0; clip-path: inset(0 100% 0 0); }\n.vs-tab-indicator-glow {\n  position: absolute; left: 0; top: 18%; bottom: -30%; width: 100px; z-index: 0; pointer-events: none; opacity: .38;\n  background: radial-gradient(50% 50% at 50% 50%, var(--vs-tab-indicator-accent), transparent 72%);\n  filter: blur(10px); transform-origin: 0 50%; will-change: transform;\n}\n.vs-tab-indicator-hi {\n  z-index: 2; clip-path: inset(0 100% 0 0);\n  background: linear-gradient(180deg, rgba(255,255,255,.3), rgba(255,255,255,0) 46%, rgba(0,0,0,.06)), var(--vs-tab-indicator-accent);\n}\n.vs-tab-indicator-hi .vs-tab-indicator-tab { position: absolute; left: 0; top: 0; color: var(--vs-tab-indicator-on-accent); pointer-events: none; transition: none; }\n.vs-tab-indicator-hi .vs-tab-indicator-count { background: rgba(0,0,0,.12); box-shadow: none; }\n\n/* variant: underline */\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-list {\n  gap: .5em; padding: 0; border-radius: 0; background: none; box-shadow: inset 0 -1px 0 var(--vs-tab-indicator-line);\n}\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-tab { height: 2.9em; padding: 0 .5em; border-radius: .3em; }\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-hi { background: none; }\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-hi .vs-tab-indicator-tab { color: var(--vs-tab-indicator-ink); }\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-hi .vs-tab-indicator-count { background: var(--vs-tab-indicator-accent); color: var(--vs-tab-indicator-on-accent); }\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-hi::after,\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-ghost::after {\n  content: \"\"; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--vs-tab-indicator-accent);\n}\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-hi::before {   /* light the bar throws upward */\n  content: \"\"; position: absolute; left: 0; right: 0; bottom: 0; height: 60%;\n  background: linear-gradient(0deg, color-mix(in srgb, var(--vs-tab-indicator-accent) 16%, transparent), transparent);\n}\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-ghost { background: none; }\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-ghost::after { background: color-mix(in srgb, var(--vs-tab-indicator-ink) 26%, transparent); }\n.vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-glow { top: 88%; bottom: -24%; opacity: .3; filter: blur(6px); }\n\n/* panels swap out-then-in: the old one fades in place (110ms), then the new one fades and\n   drifts in from the side the indicator travelled, so two panels are never legible at once */\n.vs-tab-indicator-panels { display: grid; width: 0; min-width: 100%; }   /* as wide as the tab bar */\n.vs-tab-indicator-panel {\n  grid-area: 1 / 1; min-width: 0; outline: none; border-radius: .9em;\n  transition: opacity .28s cubic-bezier(.2,0,0,1) .12s, transform .55s cubic-bezier(.16,1,.3,1) .12s, visibility 0s;\n}\n.vs-tab-indicator-panel[data-state=\"out\"] { opacity: 0; visibility: hidden; transform: none; transition: opacity .11s cubic-bezier(.4,0,1,1), transform 0s .11s, visibility 0s .11s; }\n/* phones: tighten so five tabs fit a 360px-wide screen */\n@media (max-width: 480px) {\n  .vs-tab-indicator { --vs-tab-indicator-size: 12px; --vs-tab-indicator-pad: .9em; }\n  .vs-tab-indicator-tab { padding: 0 .75em; }\n  .vs-tab-indicator[data-variant=\"underline\"] .vs-tab-indicator-tab { padding: 0 .4em; }\n  .vs-tab-indicator-card > div { padding: 1.1em 1em 1.2em; }\n}\n@media (prefers-reduced-motion: reduce) { .vs-tab-indicator-panel { transform: none !important; } }\n@media (forced-colors: active) { .vs-tab-indicator-hi { background: Highlight; } .vs-tab-indicator-hi .vs-tab-indicator-tab { color: HighlightText; } }\n\n/* example panel content (replace with your own) */\n.vs-tab-indicator-card {\n  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border-radius: .9em; overflow: hidden;\n  background: linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.015));\n  box-shadow: inset 0 0 0 .5px var(--vs-tab-indicator-line), inset 0 1px 0 rgba(255,255,255,.04);\n}\n.vs-tab-indicator-card > div { padding: 1.25em 1.35em 1.35em; min-width: 0; }\n.vs-tab-indicator-card > div + div { box-shadow: inset .5px 0 0 var(--vs-tab-indicator-line); }\n.vs-tab-indicator-card dt { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .66em; letter-spacing: .16em; text-transform: uppercase; color: color-mix(in srgb, var(--vs-tab-indicator-ink) 42%, transparent); }\n.vs-tab-indicator-card dd { margin: .8em 0 0; font-size: 1.7em; letter-spacing: -.04em; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.vs-tab-indicator-card dd small { font-size: .42em; letter-spacing: 0; margin-left: .5em; color: color-mix(in srgb, var(--vs-tab-indicator-ink) 45%, transparent); }\n.vs-tab-indicator-card dd b { font-weight: 400; color: var(--vs-tab-indicator-accent); }\n</style>\n\n<script>\n(() => {\n  if (window.vsTabIndicator) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  const live = new Set(); let raf = 0, last = 0;\n  const tick = now => {\n    const dt = Math.min(.064, (now - last) / 1000 || .016); last = now;\n    for (const t of live) { let keep = false; try { keep = t.update(dt); } catch (e) { console.error(e); } if (!keep) live.delete(t); }\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = t => { live.add(t); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  const S = (x, k, z) => ({ x, v: 0, g: x, k, c: 2 * Math.sqrt(k) * z });\n  const tune = (s, k, z) => { s.k = k; s.c = 2 * Math.sqrt(k) * z; };\n  const run = (s, n, h, eps) => {\n    if (Math.abs(s.v) < eps && Math.abs(s.x - s.g) < eps) { s.x = s.g; s.v = 0; return false; }\n    if (reduce.matches) { s.x = s.g; s.v = 0; return false; }\n    for (let i = 0; i < n; i++) { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; }\n    return true;\n  };\n  let uid = 0;\n\n  function mount(root) {\n    if (root.__vsTabs) return root.__vsTabs;\n    const list = root.querySelector('.vs-tab-indicator-list');\n    const tabs = [...list.querySelectorAll('.vs-tab-indicator-tab')];\n    const panels = [...root.querySelectorAll('.vs-tab-indicator-panel')];\n    const n0 = ++uid;\n    list.setAttribute('role', 'tablist');\n    tabs.forEach((t, i) => {\n      t.setAttribute('role', 'tab'); if (t.tagName === 'BUTTON') t.type = 'button';\n      if (!t.id) t.id = `vs-tab-indicator-${n0}-t${i}`;\n      const p = panels[i];\n      if (p) {\n        if (!p.id) p.id = `vs-tab-indicator-${n0}-p${i}`;\n        p.setAttribute('role', 'tabpanel'); p.setAttribute('aria-labelledby', t.id); t.setAttribute('aria-controls', p.id);\n        if (!p.hasAttribute('tabindex')) p.tabIndex = 0;\n      }\n    });\n    const mk = (c, parent) => { const e = document.createElement('div'); e.className = c; e.setAttribute('aria-hidden', 'true'); parent.append(e); return e; };\n    const ghost = mk('vs-tab-indicator-ghost', list), glow = mk('vs-tab-indicator-glow', list), hi = mk('vs-tab-indicator-hi', list);\n    list.prepend(glow); list.prepend(ghost);\n    const clones = tabs.map(t => { const c = document.createElement('span'); c.className = 'vs-tab-indicator-tab'; c.innerHTML = t.innerHTML; hi.append(c); return c; });\n\n    const enabled = t => t.getAttribute('aria-disabled') !== 'true' && getComputedStyle(t).display !== 'none';\n    let active = Math.max(0, tabs.findIndex(t => t.getAttribute('aria-selected') === 'true'));\n    if (!enabled(tabs[active])) active = Math.max(0, tabs.findIndex(enabled));\n    const e1 = S(0, 400, .8), e2 = S(0, 400, .8), g1 = S(0, 500, .9), g2 = S(0, 500, .9), go = S(0, 380, 1), press = S(0, 900, .8), gl = S(0, 300, .9);\n    let hover = -1, pressed = -1, box = [], Wl = 0, Hl = 0;\n    const variant = () => root.dataset.variant || 'pill';\n    const K = () => clamp(parseFloat(root.dataset.stiffness) || 420, 60, 2000);\n\n    function measure() {\n      Wl = list.clientWidth; Hl = list.clientHeight;\n      box = tabs.map((t, i) => {\n        const b = { l: t.offsetLeft, r: t.offsetLeft + t.offsetWidth, t: t.offsetTop, b: t.offsetTop + t.offsetHeight };\n        const c = clones[i]; c.style.display = t.offsetParent ? '' : 'none'; c.style.transform = `translate(${b.l}px,${b.t}px)`; c.style.width = t.offsetWidth + 'px'; c.style.height = t.offsetHeight + 'px';\n        return b;\n      });\n      const b = box[active]; e1.g = b.l; e2.g = b.r;\n      if (!measured || (Math.abs(e1.v) < .01 && Math.abs(e2.v) < .01)) { e1.x = b.l; e2.x = b.r; gl.x = gl.g = (b.l + b.r) / 2; measured = true; }\n      wake(api);\n    }\n    let measured = false;\n\n    function select(i, { focus = false, user = false } = {}) {\n      if (i < 0 || i >= tabs.length || !enabled(tabs[i])) return;\n      const prev = active; active = i;\n      tabs.forEach((t, j) => { t.setAttribute('aria-selected', String(j === i)); t.tabIndex = j === i ? 0 : -1; });\n      if (focus) tabs[i].focus();\n      const b = box[i]; if (!b) return;\n      const dir = Math.sign((b.l + b.r) - (box[prev].l + box[prev].r));\n      const k = K(), v = variant();\n      if (v === 'stretch' && dir) {\n        const lead = dir > 0 ? e2 : e1, trail = dir > 0 ? e1 : e2;\n        tune(lead, k * 1.9, .78); tune(trail, k * .5, .7);\n      } else { tune(e1, k, .8); tune(e2, k, .8); }\n      e1.g = b.l; e2.g = b.r;\n      // panels: crossfade, drifting in from the direction of travel\n      if (prev !== i && panels.length) {\n        const off = reduce.matches ? 0 : 10 * (dir || 1);\n        panels.forEach((p, j) => {\n          if (j === i) {\n            p.style.transition = 'none'; p.style.opacity = '0'; p.style.transform = `translateX(${off}px)`; p.removeAttribute('data-state'); p.inert = false; p.removeAttribute('aria-hidden');\n            p.getBoundingClientRect(); p.style.transition = ''; p.style.opacity = ''; p.style.transform = '';\n          } else if (p.getAttribute('data-state') !== 'out') {\n            p.style.transform = ''; p.setAttribute('data-state', 'out'); p.inert = true; p.setAttribute('aria-hidden', 'true');\n          }\n        });\n      }\n      if (user && prev !== i) root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { index: i, tab: tabs[i] } }));\n      wake(api);\n    }\n\n    const api = {\n      el: root, select: i => select(i), refresh: measure, get index() { return active; },\n      update(dt) {\n        const n = Math.max(1, Math.round(dt * 120)), h = dt / n, v = variant();\n        const hv = hover >= 0 && hover !== active && box[hover] ? hover : -1;\n        if (hv >= 0) {\n          if (go.x < .03) { g1.x = box[hv].l; g2.x = box[hv].r; g1.v = g2.v = 0; }   // appear in place\n          g1.g = box[hv].l; g2.g = box[hv].r; go.g = 1;\n        } else go.g = 0;\n        press.g = pressed >= 0 ? 1 : 0;\n        const k = K(); tune(g1, k * 1.2, .9); tune(g2, k * 1.2, .9); tune(gl, k * .7, .9);\n        gl.g = (e1.g + e2.g) / 2;\n        let moving = run(e1, n, h, .01) | run(e2, n, h, .01) | run(g1, n, h, .01) | run(g2, n, h, .01) | run(go, n, h, .001) | run(press, n, h, .001) | run(gl, n, h, .01);\n        const b = box[active]; if (!b) return false;\n        let x1 = e1.x, x2 = e2.x; if (x2 < x1 + 4) { const m = (x1 + x2) / 2; x1 = m - 2; x2 = m + 2; }\n        const wt = b.r - b.l, w = x2 - x1, hgt = b.b - b.t;\n        const pa = pressed === active ? press.x : 0, pg = pressed >= 0 && pressed !== active ? press.x : 0;\n        let t = b.t, bt = Hl - b.b, rad = hgt / 2;\n        if (v === 'underline') { t = 0; bt = 0; rad = 1.5; }\n        else {\n          // stretch: conserve area, so a long indicator gets thinner, then fattens back as it contracts\n          const sq = v === 'stretch' ? clamp((hgt - hgt / Math.sqrt(Math.max(1, w / wt))) * .5 * .85, 0, hgt * .22) : 0;\n          t += sq + pa * 1.5; bt += sq + pa * 1.5; x1 += pa * 1.5; x2 -= pa * 1.5; rad = (hgt - 2 * (sq + pa * 1.5)) / 2;\n        }\n        hi.style.clipPath = `inset(${t.toFixed(2)}px ${(Wl - x2).toFixed(2)}px ${bt.toFixed(2)}px ${x1.toFixed(2)}px round ${rad.toFixed(2)}px)`;\n        // ghost (hover)\n        const gb = hv >= 0 ? box[hv] : b;\n        let gx1 = g1.x + pg * 1.5, gx2 = g2.x - pg * 1.5;\n        const gt = v === 'underline' ? 0 : gb.t + pg * 1.5, gbt = v === 'underline' ? 0 : Hl - gb.b + pg * 1.5;\n        ghost.style.clipPath = `inset(${gt.toFixed(2)}px ${(Wl - gx2).toFixed(2)}px ${gbt.toFixed(2)}px ${gx1.toFixed(2)}px round ${v === 'underline' ? 1.5 : ((gb.b - gb.t) / 2 - pg * 1.5).toFixed(2)}px)`;\n        ghost.style.opacity = clamp(go.x, 0, 1).toFixed(3);\n        // glow trails the indicator's centre, stretched to its width\n        glow.style.transform = `translate3d(${(gl.x - w * .5).toFixed(2)}px,0,0) scaleX(${(w / 100).toFixed(4)})`;\n        return !!moving;\n      },\n    };\n\n    list.addEventListener('click', e => { const i = tabs.indexOf(e.target.closest('.vs-tab-indicator-tab')); if (i >= 0) select(i, { user: true }); });\n    list.addEventListener('pointerover', e => { const i = tabs.indexOf(e.target.closest('.vs-tab-indicator-tab')); hover = i >= 0 && enabled(tabs[i]) ? i : -1; wake(api); });\n    list.addEventListener('pointerleave', () => { hover = -1; pressed = -1; wake(api); });\n    list.addEventListener('pointerdown', e => { const i = tabs.indexOf(e.target.closest('.vs-tab-indicator-tab')); if (i >= 0 && enabled(tabs[i]) && e.button === 0) { pressed = i; if (e.pointerType !== 'mouse') hover = -1; wake(api); } });\n    addEventListener('pointerup', () => { if (pressed >= 0) { pressed = -1; wake(api); } });\n    addEventListener('pointercancel', () => { pressed = -1; wake(api); });\n    list.addEventListener('keydown', e => {\n      const cur = tabs.indexOf(document.activeElement); if (cur < 0) return;\n      const ok = tabs.map((t, i) => i).filter(i => enabled(tabs[i])), j = ok.indexOf(cur);\n      let to = null;\n      if (e.key === 'ArrowRight') to = ok[(j + 1) % ok.length];\n      else if (e.key === 'ArrowLeft') to = ok[(j - 1 + ok.length) % ok.length];\n      else if (e.key === 'Home') to = ok[0];\n      else if (e.key === 'End') to = ok[ok.length - 1];\n      if (to == null) return;\n      e.preventDefault(); select(to, { focus: true, user: true });\n    });\n    new ResizeObserver(measure).observe(list);\n    new MutationObserver(() => { measure(); select(active); }).observe(root, { attributes: true, attributeFilter: ['data-variant', 'style', 'class'] });\n    if (document.fonts) document.fonts.ready.then(measure);\n    // initial state\n    panels.forEach((p, j) => { if (j !== active) { p.setAttribute('data-state', 'out'); p.inert = true; p.setAttribute('aria-hidden', 'true'); } });\n    tabs.forEach((t, j) => { t.setAttribute('aria-selected', String(j === active)); t.tabIndex = j === active ? 0 : -1; if (!enabled(t)) t.setAttribute('aria-disabled', 'true'); });\n    root.__vsTabs = api; measure();\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-tab-indicator').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsTabIndicator = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Navigation",
    "tags": [
      "tabs",
      "segmented control",
      "indicator",
      "spring",
      "stretch",
      "gooey",
      "keyboard",
      "aria"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#c8f55a",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/tab-indicator/index.html",
    "thumbnail": "https://vividsites.app/api/component/tab-indicator/thumb.jpg",
    "video": "https://vividsites.app/api/component/tab-indicator/thumb.mp4",
    "page": "https://vividsites.app/c/tab-indicator"
  }
}