{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "capsule-nav",
  "type": "registry:block",
  "title": "Capsule Nav",
  "description": "A frosted rounded-full nav capsule whose active pill slides on two edge springs, stretching with its velocity while the labels re-ink under it. It hides as you scroll down, returns on the first scroll up and compacts past a threshold.",
  "docs": "Capsule Nav was added to public/vividsites/capsule-nav.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/capsule-nav",
  "categories": [
    "ui",
    "navigation"
  ],
  "files": [
    {
      "path": "components/vividsites/capsule-nav.html",
      "type": "registry:file",
      "target": "public/vividsites/capsule-nav.html",
      "content": "<!--\nvs-capsule-nav: a frosted, rounded-full nav capsule. The active pill slides on two springs,\n  one per edge: the leading edge is stiffer than the trailing one, so the pill stretches along\n  its velocity and settles back, and squashes slightly at speed. The labels under the pill are\n  re-inked with a clip (crisp inversion mid-slide). A softer \"ghost\" follows the hovered item.\n  Hide on scroll: hides when you scroll down (the scroll velocity is handed to the spring),\n  returns on the first scroll up, compacts past a threshold, comes back when the pointer\n  nears the top edge or a link takes focus.\n\n  Markup:  <nav class=\"vs-capsule-nav\" aria-label=\"Primary\"><ul class=\"vs-capsule-nav-list\">\n             <li><a class=\"vs-capsule-nav-link\" href=\"#\" aria-current=\"page\">Index</a></li> ...\n           </ul></nav>\n  Attributes on the <nav>:\n    data-hide        \"on\" (default) / \"off\": hide on scroll down\n    data-compact-at  px of scroll after which the capsule compacts (default 96)\n    data-scroll      CSS selector of a scroll container (default: the window)\n  On a link: aria-current=\"page\" marks the active item; aria-disabled=\"true\" blocks it.\n  Modifiers: .vs-capsule-nav--light (a light capsule), .vs-capsule-nav--static (in flow, not fixed).\n  Events (bubble from the <nav>): \"change\" detail {index, link}; \"capsulevisibility\" detail {hidden}.\n  API: window.vsCapsuleNav.mount(nav) -> { select(i), show(), hide() }.\n  Style with the --vs-capsule-nav-* variables below. Reduced motion: no springs, instant.\n-->\n<link href=\"https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&display=swap\" rel=\"stylesheet\">\n<nav class=\"vs-capsule-nav\" aria-label=\"Primary\" data-hide=\"on\" data-compact-at=\"96\">\n  <ul class=\"vs-capsule-nav-list\">\n    <li><a class=\"vs-capsule-nav-link\" href=\"#index\" aria-current=\"page\">Index</a></li>\n    <li><a class=\"vs-capsule-nav-link\" href=\"#work\">Work</a></li>\n    <li><a class=\"vs-capsule-nav-link\" href=\"#studio\">Studio</a></li>\n    <li><a class=\"vs-capsule-nav-link\" href=\"#journal\">Journal</a></li>\n    <li><a class=\"vs-capsule-nav-link\" href=\"#contact\">Contact</a></li>\n  </ul>\n</nav>\n\n<style>\n.vs-capsule-nav {\n  --vs-capsule-nav-size: 1rem;                 /* scales the whole capsule */\n  --vs-capsule-nav-font: \"Inter Tight\", ui-sans-serif, system-ui, sans-serif;\n  --vs-capsule-nav-accent: #f1eee6;            /* the active pill */\n  --vs-capsule-nav-accent-ink: #0b0f1a;        /* label colour on the pill */\n  --vs-capsule-nav-blur: 20px;                 /* backdrop blur (the frost) */\n  --vs-capsule-nav-frost: .4;                  /* surface opacity, 0 – 1 */\n  --vs-capsule-nav-surface: 14, 18, 32;        /* rgb of the surface */\n  --vs-capsule-nav-ink: 236, 240, 248;         /* rgb of the labels */\n  --vs-capsule-nav-line: rgba(255,255,255,.16);/* the 0.5px hairline */\n  --vs-capsule-nav-hi: rgba(255,255,255,.12);  /* inner top highlight */\n  --vs-capsule-nav-focus: #9cc0ff;\n  --vs-capsule-nav-top: 16px;                  /* distance from the top edge */\n\n  --c: 0;                                      /* compact progress, written by the script */\n  position: fixed; left: 0; right: 0; top: var(--vs-capsule-nav-top); z-index: 40;\n  display: flex; justify-content: center; pointer-events: none;\n  font-size: var(--vs-capsule-nav-size); font-family: var(--vs-capsule-nav-font);\n  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;\n}\n.vs-capsule-nav, .vs-capsule-nav * { box-sizing: border-box; }\n.vs-capsule-nav--static { position: relative; top: auto; left: auto; right: auto; }\n.vs-capsule-nav--light {\n  --vs-capsule-nav-accent: #11141d; --vs-capsule-nav-accent-ink: #f7f6f2;\n  --vs-capsule-nav-frost: .56; --vs-capsule-nav-surface: 250, 250, 252; --vs-capsule-nav-ink: 17, 20, 29;\n  --vs-capsule-nav-line: rgba(10,14,30,.1); --vs-capsule-nav-hi: rgba(255,255,255,.75); --vs-capsule-nav-focus: #2f5bff;\n}\n.vs-capsule-nav-bar {\n  position: relative; display: flex; pointer-events: auto; padding: .3125em; border-radius: 999px;\n  background: rgba(var(--vs-capsule-nav-surface), var(--vs-capsule-nav-frost));\n  -webkit-backdrop-filter: blur(var(--vs-capsule-nav-blur)) saturate(1.8);\n  backdrop-filter: blur(var(--vs-capsule-nav-blur)) saturate(1.8);\n  box-shadow:\n    inset 0 0 0 .5px var(--vs-capsule-nav-line),\n    inset 0 1px 0 var(--vs-capsule-nav-hi),\n    inset 0 -1px 1px rgba(0,0,0,.14),\n    0 1px 2px rgba(3,6,16,.22),\n    0 .75em 2em -.75em rgba(3,6,16,.5),\n    0 2em 4em -2em rgba(3,6,16,.45);\n  transform-origin: 50% 0; will-change: transform;\n}\n/* sheen + the extra frost when compact */\n.vs-capsule-nav-bar::before, .vs-capsule-nav-bar::after {\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;\n}\n.vs-capsule-nav-bar::before { background: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,0) 55%); }\n.vs-capsule-nav--light .vs-capsule-nav-bar::before { background: linear-gradient(180deg, rgba(255,255,255,.5), rgba(255,255,255,0) 60%); }\n.vs-capsule-nav-bar::after { background: rgba(var(--vs-capsule-nav-surface), .28); opacity: var(--c); }\n.vs-capsule-nav-track { position: relative; z-index: 1; display: flex; }\n.vs-capsule-nav-list { display: flex; gap: .125em; margin: 0; padding: 0; list-style: none; }\n.vs-capsule-nav-list > li { display: block; margin: 0; }\n.vs-capsule-nav-link {\n  position: relative; display: block; padding: .8125em 1.25em .875em; border-radius: 999px;\n  font: 500 1em/1 var(--vs-capsule-nav-font); letter-spacing: -.012em; white-space: nowrap;\n  color: rgba(var(--vs-capsule-nav-ink), .66); text-decoration: none; outline: none;\n  -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  transition: color 150ms cubic-bezier(.2,0,0,1);\n}\n.vs-capsule-nav-link:hover, .vs-capsule-nav-link.is-hot { color: rgba(var(--vs-capsule-nav-ink), 1); }\n.vs-capsule-nav-link:focus-visible { box-shadow: 0 0 0 2px rgba(var(--vs-capsule-nav-surface), .9), 0 0 0 3.5px var(--vs-capsule-nav-focus); }\n.vs-capsule-nav-link[aria-disabled=\"true\"] { color: rgba(var(--vs-capsule-nav-ink), .3); cursor: not-allowed; }\n/* hover ghost, the pill and its shadow: shaped by clip-path / transform from the script */\n.vs-capsule-nav-ghost, .vs-capsule-nav-pill { position: absolute; inset: 0; pointer-events: none; clip-path: inset(0 100% 0 0 round 999px); }\n.vs-capsule-nav-ghost { background: rgba(var(--vs-capsule-nav-ink), .1); opacity: 0; }\n.vs-capsule-nav-shade {\n  position: absolute; left: 0; top: 0; bottom: 0; width: 100px; border-radius: 999px; pointer-events: none;\n  transform-origin: 0 50%; box-shadow: 0 .25em .75em -.125em rgba(3,6,16,.4), 0 1px 2px rgba(3,6,16,.25);\n}\n.vs-capsule-nav--light .vs-capsule-nav-shade { box-shadow: 0 .25em .75em -.125em rgba(10,14,30,.28), 0 1px 2px rgba(10,14,30,.2); }\n.vs-capsule-nav-pill {\n  background: linear-gradient(180deg, rgba(255,255,255,.34), rgba(255,255,255,0) 58%), var(--vs-capsule-nav-accent);\n}\n.vs-capsule-nav--light .vs-capsule-nav-pill { background: linear-gradient(180deg, rgba(255,255,255,.16), rgba(255,255,255,0) 58%), var(--vs-capsule-nav-accent); }\n.vs-capsule-nav-pill .vs-capsule-nav-list { position: absolute; left: 0; top: 0; }\n.vs-capsule-nav-pill .vs-capsule-nav-link { color: var(--vs-capsule-nav-accent-ink); transition: none; }\n@media (max-width: 480px) { .vs-capsule-nav-link { padding-inline: .9em; } }\n@media (forced-colors: active) {\n  .vs-capsule-nav-bar { border: 1px solid CanvasText; }\n  .vs-capsule-nav-pill { background: Highlight; } .vs-capsule-nav-pill .vs-capsule-nav-link { color: HighlightText; }\n}\n</style>\n\n<script>\n(() => {\n  if (window.vsCapsuleNav) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  /* one shared ticker: runs only while a spring is moving */\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) if (!t.update(dt)) 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  /* semi-implicit Euler spring, 120 Hz substeps; e = settle epsilon in the spring's own units */\n  const sp = (x, k, c, e = .05) => ({ x, v: 0, g: x, k, c, e });\n  const run = (s, dt) => {\n    if (reduce.matches) { s.x = s.g; s.v = 0; return false; }\n    const n = Math.max(1, Math.ceil(dt * 120)), h = dt / n;\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    if (Math.abs(s.v) < s.e && Math.abs(s.x - s.g) < s.e) { s.x = s.g; s.v = 0; return false; }\n    return true;\n  };\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  /* aim a pair of edge springs: the edge in the direction of travel leads (stiffer), the other trails */\n  const aim = (L, R, l, r, lead, trail) => {\n    const dir = (l + r) - (L.g + R.g);\n    const [a, b] = dir >= 0 ? [R, L] : [L, R];\n    a.k = lead[0]; a.c = lead[1]; b.k = trail[0]; b.c = trail[1];\n    L.g = l; R.g = r;\n  };\n  const PILL = [[560, 36], [240, 27]], GHOST = [[440, 36], [230, 29]];\n\n  function mount(nav) {\n    if (nav.__vsCn) return nav.__vsCn;\n    const ul = nav.querySelector('.vs-capsule-nav-list'); if (!ul) return null;\n    const mk = (c, t = 'div') => { const e = document.createElement(t); e.className = c; return e; };\n    const bar = mk('vs-capsule-nav-bar'), track = mk('vs-capsule-nav-track');\n    const ghost = mk('vs-capsule-nav-ghost', 'span'), shade = mk('vs-capsule-nav-shade', 'span'), pill = mk('vs-capsule-nav-pill', 'span');\n    [ghost, shade, pill].forEach(e => e.setAttribute('aria-hidden', 'true'));\n    ul.before(bar); bar.append(track); track.append(ghost, shade, ul, pill);\n    // the labels again, in the pill's ink, clipped by the pill: crisp inversion while it slides\n    const ink = ul.cloneNode(true); ink.removeAttribute('id');\n    ink.querySelectorAll('a').forEach(a => { a.removeAttribute('href'); a.removeAttribute('id'); a.removeAttribute('aria-current'); a.tabIndex = -1; });\n    pill.append(ink); pill.inert = true;\n    const links = [...ul.querySelectorAll('.vs-capsule-nav-link')];\n    const off = a => a.getAttribute('aria-disabled') === 'true';\n\n    let G = [], W = 0, H = 0, D = 80;\n    const measure = () => {\n      W = track.offsetWidth; H = track.offsetHeight;\n      G = links.map(a => ({ l: a.offsetLeft, r: a.offsetLeft + a.offsetWidth }));\n      D = (nav.getBoundingClientRect().top || 0) + bar.offsetHeight + 28;\n    };\n    measure();\n    let active = Math.max(0, links.findIndex(a => a.getAttribute('aria-current') === 'page'));\n    const S = {\n      L: sp(G[active].l, 560, 36), R: sp(G[active].r, 560, 36),\n      gl: sp(G[active].l, 440, 36), gr: sp(G[active].r, 440, 36), go: sp(0, 320, 30, .002),\n      press: sp(0, 700, 40, .002), hy: sp(0, 300, 32, .1), cp: sp(0, 170, 26, .002),\n    };\n    let hot = -1, pressed = -1, hidden = false;\n\n    const api = {\n      update(dt) {\n        let moving = false;\n        for (const k in S) if (run(S[k], dt)) moving = true;\n        const { L, R, gl, gr, go, press, hy, cp } = S;\n        // pill: velocity squash (px/s -> px), press pinch\n        const v = (L.v + R.v) / 2, pp = pressed === active ? press.x : 0;\n        const sq = Math.min(H * .1, Math.abs(v) * .0011) + pp * 1.6;\n        const l = L.x + pp * 2, r = R.x - pp * 2;\n        pill.style.clipPath = `inset(${sq.toFixed(2)}px ${(W - r).toFixed(2)}px ${sq.toFixed(2)}px ${l.toFixed(2)}px round 999px)`;\n        shade.style.transform = `translate3d(${l.toFixed(2)}px,0,0) scale(${((r - l) / 100).toFixed(4)},${(1 - 2 * sq / H).toFixed(4)})`;\n        const gp = pressed >= 0 && pressed !== active ? press.x * 1.5 : 0;\n        ghost.style.clipPath = `inset(${gp.toFixed(2)}px ${(W - gr.x + gp).toFixed(2)}px ${gp.toFixed(2)}px ${(gl.x + gp).toFixed(2)}px round 999px)`;\n        ghost.style.opacity = go.x.toFixed(3);\n        const hp = clamp(hy.x / D, -.2, 1);\n        bar.style.transform = `translate3d(0,${(-hy.x).toFixed(2)}px,0) scale(${(1 - .07 * cp.x - .05 * Math.max(0, hp)).toFixed(4)})`;\n        bar.style.opacity = (1 - .45 * Math.max(0, hp)).toFixed(3);\n        nav.style.setProperty('--c', cp.x.toFixed(3));\n        return moving;\n      },\n      select(i, user = false) {\n        if (i < 0 || i >= links.length || off(links[i])) return;\n        const changed = i !== active; active = i;\n        links.forEach((a, j) => j === i ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current'));\n        aim(S.L, S.R, G[i].l, G[i].r, ...PILL);\n        wake(api);\n        if (changed && user) nav.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { index: i, link: links[i] } }));\n      },\n      show: () => setHidden(false, 0), hide: () => setHidden(true, 0),\n      get hidden() { return hidden; },\n    };\n\n    function hover(i) {\n      if (i === hot) return; hot = i;\n      links.forEach((a, j) => a.classList.toggle('is-hot', j === i));\n      if (i < 0) { S.go.g = 0; wake(api); return; }\n      if (S.go.x < .06) { S.gl.x = S.gl.g = G[i].l; S.gr.x = S.gr.g = G[i].r; S.gl.v = S.gr.v = 0; } // snap in, never sweep from far\n      else aim(S.gl, S.gr, G[i].l, G[i].r, ...GHOST);\n      S.go.g = 1; wake(api);\n    }\n    const idx = e => { const a = e.target.closest && e.target.closest('.vs-capsule-nav-link'); return a ? links.indexOf(a) : -1; };\n    track.addEventListener('pointerover', e => { const i = idx(e); if (i >= 0 && !off(links[i])) hover(i); });\n    track.addEventListener('pointerleave', () => { if (!track.contains(document.activeElement) || !track.querySelector(':focus-visible')) hover(-1); });\n    track.addEventListener('pointerdown', e => { const i = idx(e); if (i < 0 || off(links[i]) || e.button > 0) return; pressed = i; S.press.g = 1; wake(api); });\n    const release = () => { S.press.g = 0; wake(api); setTimeout(() => { if (S.press.g === 0) pressed = -1; }, 400); };\n    addEventListener('pointerup', release); addEventListener('pointercancel', release);\n    ul.addEventListener('click', e => {\n      const i = idx(e); if (i < 0) return;\n      if (off(links[i])) { e.preventDefault(); return; }\n      api.select(i, true);\n    });\n    ul.addEventListener('focusin', e => { const i = idx(e); if (i >= 0 && e.target.matches(':focus-visible')) hover(i); setHidden(false, 0); });\n    ul.addEventListener('focusout', () => setTimeout(() => { if (!ul.contains(document.activeElement)) hover(-1); }));\n    ul.addEventListener('keydown', e => {\n      const i = links.indexOf(document.activeElement); if (i < 0) return;\n      const pick = { ArrowRight: 1, ArrowLeft: -1, Home: -99, End: 99 }[e.key]; if (!pick) return;\n      e.preventDefault();\n      let j = pick === -99 ? 0 : pick === 99 ? links.length - 1 : i + pick, n = links.length;\n      while (j >= 0 && j < n && off(links[j])) j += Math.sign(pick);\n      if (j >= 0 && j < n) links[j].focus();\n    });\n\n    /* hide on scroll: direction with a small dead zone; the scroll velocity is handed to the spring */\n    const scroller = nav.dataset.scroll ? document.querySelector(nav.dataset.scroll) : null;\n    const getY = () => scroller ? scroller.scrollTop : (window.scrollY || document.documentElement.scrollTop);\n    let lastY = getY(), lastT = performance.now(), vel = 0, acc = 0;\n    function setHidden(h, v) {\n      if (h === hidden) return; hidden = h;\n      S.hy.g = h ? D : 0;\n      S.hy.k = h ? 300 : 360; S.hy.c = h ? 32 : 27;              // returning lands with a hint of overshoot\n      S.hy.v += clamp(v * .7, -2600, 2600);\n      wake(api);\n      nav.dispatchEvent(new CustomEvent('capsulevisibility', { bubbles: true, detail: { hidden: h } }));\n    }\n    const onScroll = () => {\n      const y = getY(), now = performance.now(), dy = y - lastY, dtm = Math.max(8, now - lastT);\n      vel = vel * .5 + (dy / dtm * 1000) * .5; lastY = y; lastT = now;\n      const at = parseFloat(nav.dataset.compactAt ?? 96);\n      S.cp.g = y > at ? 1 : 0;\n      if (nav.dataset.hide === 'off' || y <= Math.max(8, at * .5)) { acc = 0; setHidden(false, 0); }\n      else if (dy) {\n        if (Math.sign(dy) !== Math.sign(acc)) acc = 0;\n        acc += dy;\n        if (acc > 14) setHidden(true, vel);\n        else if (acc < -6) setHidden(false, vel);\n      }\n      wake(api);\n    };\n    (scroller || window).addEventListener('scroll', onScroll, { passive: true });\n    addEventListener('pointermove', e => { if (hidden && e.pointerType === 'mouse' && e.clientY < 28) { acc = 0; setHidden(false, 0); } }, { passive: true });\n    new MutationObserver(() => { if (nav.dataset.hide === 'off') setHidden(false, 0); }).observe(nav, { attributes: true, attributeFilter: ['data-hide'] });\n    new ResizeObserver(() => {\n      measure();\n      const a = G[active]; S.L.g = a.l; S.R.g = a.r;\n      if (!live.has(api)) { S.L.x = a.l; S.R.x = a.r; }\n      if (hot >= 0) { S.gl.g = G[hot].l; S.gr.g = G[hot].r; }\n      if (hidden) S.hy.g = D;\n      wake(api);\n    }).observe(ul);\n\n    nav.__vsCn = api; api.update(0); onScroll();\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-capsule-nav').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsCapsuleNav = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Navigation",
    "tags": [
      "nav",
      "navbar",
      "pill",
      "glass",
      "frosted",
      "spring",
      "hide on scroll",
      "tabs"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#9ccbff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/capsule-nav/index.html",
    "thumbnail": "https://vividsites.app/api/component/capsule-nav/thumb.jpg",
    "video": "https://vividsites.app/api/component/capsule-nav/thumb.mp4",
    "page": "https://vividsites.app/c/capsule-nav"
  }
}