{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segmented",
  "type": "registry:block",
  "title": "Segmented Control",
  "description": "A segmented control whose thumb rides two springs, one per edge, so it stretches toward its target and settles behind it; the selected label colour is clipped to the thumb, so text changes colour exactly where it passes. Drag the thumb to scrub, or use arrows, Home and End.",
  "docs": "Segmented Control was added to public/vividsites/segmented.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/segmented",
  "categories": [
    "ui",
    "forms"
  ],
  "files": [
    {
      "path": "components/vividsites/segmented.html",
      "type": "registry:file",
      "target": "public/vividsites/segmented.html",
      "content": "<!--\nvs-segmented: a segmented control whose thumb slides on two springs, one per edge. The\n  leading edge is stiffer than the trailing one, so the thumb stretches toward where it is\n  going and settles behind it. The selected label colour is a second copy of the labels clipped\n  to the thumb, so text changes colour exactly where the thumb covers it, mid-flight.\n  Press the thumb and drag across to scrub (iOS style); release picks the segment.\n  Keyboard: it is a radiogroup with roving focus. Arrows (wrapping), Home and End.\n\n  Markup:\n    <div class=\"vs-segmented\" role=\"radiogroup\" aria-label=\"View\">\n      <button type=\"button\" role=\"radio\" aria-checked=\"true\" value=\"list\"><svg>…</svg>List</button>\n      <button type=\"button\" role=\"radio\" aria-checked=\"false\" value=\"board\">Board</button>\n    </div>\n  Buttons may hold an <svg> icon, text, or both; add aria-label to icon-only buttons.\n  A button with `disabled` is skipped.\n  Root attributes:\n    data-vs-segmented-theme   \"light\" (default) | \"accent\" (thumb in the accent colour) | \"dark\" | \"dark accent\"\n    data-vs-segmented-fit     \"equal\" (default, equal widths) | \"content\" (each segment hugs its label)\n    data-name                 if set, a hidden <input> with this name carries the value (for forms)\n  JS: el.vsSegmented.select(indexOrValue)  el.vsSegmented.value\n  Events: \"change\" bubbles from the root with detail { value, index }.\n  Reduced motion: the thumb moves instantly.\n-->\n<style>\n.vs-segmented {\n  --vs-segmented-size: 1rem;                  /* scales everything */\n  --vs-segmented-font: inherit;\n  --vs-segmented-accent: #1f4d3a;             /* thumb colour in the accent theme */\n  --vs-segmented-well: rgba(24,30,24,.075);   /* the track */\n  --vs-segmented-thumb: #ffffff;\n  --vs-segmented-ink: #161a16;                /* selected label */\n  --vs-segmented-muted: rgba(22,26,22,.52);   /* other labels */\n  --vs-segmented-radius: 12px;                /* outer radius; the thumb follows it */\n  --vs-segmented-pad: 3px;\n  --vs-segmented-bounce: .35;                 /* 0 = no overshoot … 1 = very springy */\n  --vs-segmented-focus: #2f6bff;\n\n  position: relative; display: inline-grid; grid-auto-flow: column; grid-auto-columns: 1fr;\n  padding: var(--vs-segmented-pad); border-radius: var(--vs-segmented-radius);\n  background: var(--vs-segmented-well);\n  box-shadow: inset 0 0 0 .5px rgba(0,0,0,.06), inset 0 1px 2px rgba(0,0,0,.05);\n  font-family: var(--vs-segmented-font); font-size: var(--vs-segmented-size);\n  user-select: none; -webkit-user-select: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n  -webkit-font-smoothing: antialiased; isolation: isolate;\n}\n.vs-segmented *, .vs-segmented *::before, .vs-segmented *::after { box-sizing: border-box; }\n.vs-segmented[data-vs-segmented-fit=\"content\"] { grid-auto-columns: auto; }\n.vs-segmented[data-vs-segmented-theme~=\"dark\"] {\n  --vs-segmented-well: rgba(255,255,255,.07); --vs-segmented-thumb: #383c3d; --vs-segmented-ink: #ffffff; --vs-segmented-muted: rgba(255,255,255,.55);\n  box-shadow: inset 0 0 0 .5px rgba(255,255,255,.07), inset 0 1px 2px rgba(0,0,0,.3);\n}\n.vs-segmented[data-vs-segmented-theme~=\"accent\"] { --vs-segmented-thumb: var(--vs-segmented-accent); --vs-segmented-ink: #ffffff; }\n\n/* segments */\n.vs-segmented > button {\n  all: unset; box-sizing: border-box; position: relative; z-index: 1;\n  display: flex; align-items: center; justify-content: center; gap: .5em;\n  height: 2.5em; padding: 0 1.05em; border-radius: calc(var(--vs-segmented-radius) - var(--vs-segmented-pad));\n  font: inherit; font-size: 1em; line-height: 1; letter-spacing: -.005em; white-space: nowrap;\n  color: var(--vs-segmented-muted); cursor: pointer; outline: none;\n  transition: color .2s cubic-bezier(.2,0,0,1);\n}\n.vs-segmented > button > * { pointer-events: none; }\n.vs-segmented > button svg { width: 1.15em; height: 1.15em; flex: none; }\n.vs-segmented > button .vs-segmented-c { display: inline-flex; align-items: center; gap: inherit; transition: transform .35s cubic-bezier(.34,1.56,.64,1); }\n.vs-segmented > button:active .vs-segmented-c { transform: scale(.94); transition-duration: .12s; }\n/* hover: a faint pill on segments the thumb isn't on */\n.vs-segmented > button::before {\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; background: currentColor; opacity: 0;\n  transition: opacity .15s cubic-bezier(.2,0,0,1); z-index: -1;\n}\n.vs-segmented > button:hover:not([aria-checked=\"true\"]):not(:disabled)::before { opacity: .06; }\n.vs-segmented > button:hover:not([aria-checked=\"true\"]) { color: color-mix(in srgb, var(--vs-segmented-muted) 55%, var(--vs-segmented-ink)); }\n.vs-segmented > button:disabled { opacity: .38; cursor: not-allowed; }\n/* hairline dividers between segments, hidden next to the thumb */\n.vs-segmented > button + button::after {\n  content: \"\"; position: absolute; left: 0; top: 28%; bottom: 28%; width: 1px; transform: translateX(-.5px) scaleX(.5);\n  background: var(--vs-segmented-muted); opacity: .45; transition: opacity .2s;\n}\n.vs-segmented > button.vs-segmented-near::after, .vs-segmented > button[aria-checked=\"true\"]::after { opacity: 0; }\n.vs-segmented.is-drag > button::after { opacity: 0; }\n\n/* the thumb: positioned by the script (transform + width); the inner <i> carries the look */\n.vs-segmented-thumb { position: absolute; left: 0; top: var(--vs-segmented-pad); bottom: var(--vs-segmented-pad); z-index: 2; /* above the muted labels, below the selected copy */ pointer-events: none; will-change: transform, width; }\n.vs-segmented-thumb i {\n  position: absolute; inset: 0; border-radius: calc(var(--vs-segmented-radius) - var(--vs-segmented-pad));\n  background: var(--vs-segmented-thumb);\n  box-shadow: 0 0 0 .5px rgba(0,0,0,.08), 0 1px 1px rgba(0,0,0,.04), 0 3px 8px -2px rgba(0,0,0,.14), inset 0 .5px 0 rgba(255,255,255,.7);\n  transition: transform .3s cubic-bezier(.34,1.56,.64,1), background-color .25s;\n}\n.vs-segmented[data-vs-segmented-theme~=\"accent\"] .vs-segmented-thumb i { box-shadow: 0 0 0 .5px rgba(0,0,0,.2), 0 2px 6px -1px color-mix(in srgb, var(--vs-segmented-accent) 55%, transparent), inset 0 .5px 0 rgba(255,255,255,.22); }\n.vs-segmented[data-vs-segmented-theme~=\"dark\"]:not([data-vs-segmented-theme~=\"accent\"]) .vs-segmented-thumb i { box-shadow: 0 0 0 .5px rgba(255,255,255,.08), 0 3px 8px -2px rgba(0,0,0,.5), inset 0 .5px 0 rgba(255,255,255,.14); }\n.vs-segmented.is-press .vs-segmented-thumb i { transform: scale(.955); transition-duration: .16s; }\n\n/* the selected-colour copy of the labels, clipped to the thumb by the script */\n.vs-segmented-hi {\n  position: absolute; inset: 0; z-index: 3; pointer-events: none;\n  display: grid; grid-auto-flow: column; grid-auto-columns: inherit; padding: inherit;\n  will-change: clip-path;\n}\n.vs-segmented-hi > span {\n  display: flex; align-items: center; justify-content: center; gap: .5em; height: 2.5em; padding: 0 1.05em;\n  white-space: nowrap; color: var(--vs-segmented-ink); line-height: 1; letter-spacing: -.005em;\n}\n.vs-segmented-hi svg { width: 1.15em; height: 1.15em; flex: none; }\n.vs-segmented-hi .vs-segmented-c { display: inline-flex; align-items: center; gap: inherit; transition: transform .35s cubic-bezier(.34,1.56,.64,1); }\n.vs-segmented-hi > span.is-down .vs-segmented-c { transform: scale(.94); transition-duration: .12s; }\n\n/* keyboard focus: a ring around the thumb */\n.vs-segmented-thumb::after {\n  content: \"\"; position: absolute; inset: -3px; border-radius: calc(var(--vs-segmented-radius) - var(--vs-segmented-pad) + 3px);\n  box-shadow: 0 0 0 2px var(--vs-segmented-focus); opacity: 0; transition: opacity .15s;\n}\n.vs-segmented.is-kbd:focus-within .vs-segmented-thumb::after { opacity: 1; }\n</style>\n\n<div class=\"vs-segmented\" role=\"radiogroup\" aria-label=\"View\">\n  <button type=\"button\" role=\"radio\" aria-checked=\"true\" value=\"list\">List</button>\n  <button type=\"button\" role=\"radio\" aria-checked=\"false\" value=\"board\">Board</button>\n  <button type=\"button\" role=\"radio\" aria-checked=\"false\" value=\"timeline\">Timeline</button>\n</div>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n\n  function init(root) {\n    if (root.vsSegmented) return;\n    const btns = [...root.querySelectorAll(':scope > button')];\n    // wrap each label so press-scale doesn't fight the layout\n    btns.forEach((b) => { if (!b.querySelector('.vs-segmented-c')) { const c = document.createElement('span'); c.className = 'vs-segmented-c'; while (b.firstChild) c.appendChild(b.firstChild); b.appendChild(c); } });\n    const thumb = document.createElement('div'); thumb.className = 'vs-segmented-thumb'; thumb.innerHTML = '<i></i>';\n    root.prepend(thumb);\n    // selected-colour copy of every label\n    const hi = document.createElement('div'); hi.className = 'vs-segmented-hi'; hi.setAttribute('aria-hidden', 'true');\n    const his = btns.map((b) => { const s = document.createElement('span'); s.innerHTML = b.innerHTML; hi.appendChild(s); return s; });\n    root.appendChild(hi);\n    let hidden = null;\n    if (root.dataset.name) { hidden = document.createElement('input'); hidden.type = 'hidden'; hidden.name = root.dataset.name; root.appendChild(hidden); }\n\n    let idx = Math.max(0, btns.findIndex((b) => b.getAttribute('aria-checked') === 'true'));\n    // two springs: left and right edge (px). The leading edge gets the stiffer one.\n    const L = { x: 0, v: 0, t: 0 }, R = { x: 0, v: 0, t: 0 };\n    let kL = 420, kR = 420, raf = 0, last = 0, W = 0;\n    const measure = () => { W = root.clientWidth; };\n    const edges = (i) => { const b = btns[i]; return [b.offsetLeft, b.offsetLeft + b.offsetWidth]; };\n    const paint = () => {\n      const l = L.x, r = Math.max(l + 8, R.x);\n      thumb.style.transform = `translateX(${l.toFixed(2)}px)`;\n      thumb.style.width = (r - l).toFixed(2) + 'px';\n      hi.style.clipPath = `inset(var(--vs-segmented-pad) ${(W - r).toFixed(2)}px var(--vs-segmented-pad) ${l.toFixed(2)}px round calc(var(--vs-segmented-radius) - var(--vs-segmented-pad)))`;\n    };\n    const damping = (k) => { // bounce 0..1 -> damping ratio 1..0.45\n      const b = Math.min(1, Math.max(0, parseFloat(getComputedStyle(root).getPropertyValue('--vs-segmented-bounce')) || 0));\n      return 2 * Math.sqrt(k) * (1 - b * .55);\n    };\n    const step = (now) => {\n      const dt = Math.min(.032, (now - last) / 1000 || .016); last = now;\n      const cL = damping(kL), cR = damping(kR);\n      for (let i = 0; i < 3; i++) {\n        const h = dt / 3;\n        L.v += (-kL * (L.x - L.t) - cL * L.v) * h; L.x += L.v * h;\n        R.v += (-kR * (R.x - R.t) - cR * R.v) * h; R.x += R.v * h;\n      }\n      const moving = Math.abs(L.v) + Math.abs(R.v) > .5 || Math.abs(L.x - L.t) + Math.abs(R.x - R.t) > .1;\n      if (!moving) { L.x = L.t; R.x = R.t; L.v = R.v = 0; }\n      paint();\n      raf = moving ? requestAnimationFrame(step) : 0;\n    };\n    const goTo = (i, snap) => {\n      const [l, r] = edges(i);\n      const right = l > L.t;\n      kL = right ? 300 : 620; kR = right ? 620 : 300; // leading edge is stiffer, so the thumb stretches\n      L.t = l; R.t = r;\n      if (snap || reduce.matches) { L.x = l; R.x = r; L.v = R.v = 0; paint(); return; }\n      if (!raf) { last = performance.now(); raf = requestAnimationFrame(step); }\n    };\n    const near = (i) => btns.forEach((b, j) => b.classList.toggle('vs-segmented-near', j === i + 1));\n\n    const select = (i, opts = {}) => {\n      if (typeof i === 'string') i = btns.findIndex((b) => b.value === i);\n      if (i < 0 || i >= btns.length || btns[i].disabled) return;\n      const changed = i !== idx; idx = i;\n      btns.forEach((b, j) => { b.setAttribute('aria-checked', j === i); b.tabIndex = j === i ? 0 : -1; });\n      near(i); goTo(i, opts.snap);\n      if (hidden) hidden.value = btns[i].value;\n      if (opts.focus) btns[i].focus({ preventScroll: true });\n      if (changed && !opts.silent) root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: btns[i].value, index: i } }));\n    };\n\n    // pointer: tap selects on release; pressing the selected thumb and dragging scrubs across segments\n    let drag = null;\n    const at = (x) => { let best = idx, d = 1e9; btns.forEach((b, j) => { if (b.disabled) return; const c = b.offsetLeft + b.offsetWidth / 2; const dd = Math.abs(c - x); if (dd < d) { d = dd; best = j; } }); return best; };\n    root.addEventListener('pointerdown', (e) => {\n      const b = e.target.closest('.vs-segmented > button'); if (!b || b.disabled || e.button > 0) return;\n      root.classList.remove('is-kbd');\n      const i = btns.indexOf(b);\n      drag = { id: e.pointerId, onThumb: i === idx, start: e.clientX, i, moved: false };\n      his[i].classList.add('is-down');\n      if (drag.onThumb) { root.classList.add('is-press'); root.setPointerCapture(e.pointerId); }\n    });\n    root.addEventListener('pointermove', (e) => {\n      if (!drag || e.pointerId !== drag.id || !drag.onThumb) return;\n      if (Math.abs(e.clientX - drag.start) > 4) { drag.moved = true; root.classList.add('is-drag'); }\n      if (!drag.moved) return;\n      const x = e.clientX - root.getBoundingClientRect().left;\n      const j = at(x);\n      if (j !== drag.cur) { drag.cur = j; goTo(j); }\n    });\n    const end = (e, cancel) => {\n      if (!drag || e.pointerId !== drag.id) return;\n      root.classList.remove('is-press', 'is-drag');\n      his.forEach((s) => s.classList.remove('is-down'));\n      const d = drag; drag = null;\n      if (cancel) { goTo(idx); return; }\n      if (d.onThumb && d.moved) select(d.cur ?? idx);\n      else { const b = document.elementFromPoint(e.clientX, e.clientY); const j = btns.indexOf(b && b.closest('.vs-segmented > button')); if (j === d.i) select(j); }\n    };\n    root.addEventListener('pointerup', (e) => end(e));\n    root.addEventListener('pointercancel', (e) => end(e, true));\n    root.addEventListener('lostpointercapture', (e) => { if (drag && drag.onThumb) end(e); });\n    // a real click from a keyboard (Space/Enter on a button) or assistive tech\n    root.addEventListener('click', (e) => { if (e.detail === 0) { const j = btns.indexOf(e.target.closest('.vs-segmented > button')); if (j >= 0) select(j); } });\n\n    root.addEventListener('keydown', (e) => {\n      const n = btns.length; let j = idx;\n      const move = (d) => { do { j = (j + d + n) % n; } while (btns[j].disabled && j !== idx); };\n      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') move(1);\n      else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') move(-1);\n      else if (e.key === 'Home') { j = -1; move(1); }\n      else if (e.key === 'End') { j = n; move(-1); }\n      else return;\n      e.preventDefault(); root.classList.add('is-kbd'); select(j, { focus: true });\n    });\n    root.addEventListener('focusin', () => { if (!drag) root.classList.add('is-kbd'); });\n    root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) root.classList.remove('is-kbd'); });\n\n    // keep the thumb glued to its segment through resizes and font loads\n    const relayout = () => { measure(); const [l, r] = edges(idx); if (!raf) { L.x = L.t = l; R.x = R.t = r; paint(); } else { L.t = l; R.t = r; } };\n    new ResizeObserver(relayout).observe(root);\n    if (document.fonts) document.fonts.ready.then(relayout);\n\n    root.vsSegmented = { select: (i) => select(i), get value() { return btns[idx].value; }, get index() { return idx; } };\n    measure(); select(idx, { snap: true, silent: true });\n  }\n\n  const boot = () => document.querySelectorAll('.vs-segmented').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n  window.vsSegmentedInit = boot;\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Forms",
    "tags": [
      "segmented control",
      "tabs",
      "toggle group",
      "radio group",
      "switcher",
      "spring",
      "flip",
      "keyboard"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Outfit:wght@400&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#1f4d3a",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/segmented/index.html",
    "thumbnail": "https://vividsites.app/api/component/segmented/thumb.jpg",
    "video": "https://vividsites.app/api/component/segmented/thumb.mp4",
    "page": "https://vividsites.app/c/segmented"
  }
}