{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dock",
  "type": "registry:block",
  "title": "Magnifying Dock",
  "description": "A frosted dock whose tiles swell on a springed cosine falloff while their neighbours slide apart, keeping the spot under the pointer fixed. Clicking launches a tile with a squash-and-hop and a running dot; touch magnifies as you press and slide.",
  "docs": "Magnifying Dock was added to public/vividsites/dock.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/dock",
  "categories": [
    "ui",
    "navigation"
  ],
  "files": [
    {
      "path": "components/vividsites/dock.html",
      "type": "registry:file",
      "target": "public/vividsites/dock.html",
      "content": "<!--\nvs-dock: a magnifying dock. Tiles swell on a cosine falloff around a springed pointer, and\n  the neighbours slide apart so the shelf's width is always the true sum of the tiles: the\n  spot under your pointer stays put while everything else makes room. Click launches: the\n  tile hops twice, squashes on each landing and a running dot springs in; a running app\n  gives one short hop. Labels spring in above the tile. On touch, press and slide your\n  finger along the dock; lifting it launches the tile under it. The toolbar is keyboard\n  navigable (arrows, Home, End; focus magnifies; Enter launches).\n\n  Markup:  <nav class=\"vs-dock\" aria-label=\"Dock\"><ul class=\"vs-dock-list\">\n             <li class=\"vs-dock-item\" data-running><button class=\"vs-dock-btn\" aria-label=\"Relay\">\n               <span class=\"vs-dock-tile\" style=\"--g:linear-gradient(...)\"><svg>...</svg></span>\n             </button></li>\n             <li class=\"vs-dock-sep\"></li> ...\n           </ul></nav>\n  Attributes on .vs-dock:\n    data-magnify   peak scale of the tile under the pointer, 1 – 3 (default 1.9)\n    data-range     how many tiles either side feel it, .5 – 8 (default 3)\n    data-size      tile size in px (else --vs-dock-size); the dock shrinks tiles to fit its container\n  On an item:  data-running (shows the dot), aria-disabled=\"true\" on the button (dims, no launch),\n               hidden (removes it from the layout)\n  Modifier: .vs-dock--dark gives a smoked shelf and dark labels.\n  CSS variables: --vs-dock-size, --vs-dock-shelf, --vs-dock-shelf-line, --vs-dock-tip-bg,\n    --vs-dock-tip-ink, --vs-dock-dot, --vs-dock-focus, --vs-dock-font; each tile's --g is its gradient.\n  Events: \"launch\" bubbles from the button, detail { label, running }.\n  JS: vsDock.mount(el) for docks added later; el.__vsDock.refresh() after changing sizes;\n      el.__vsDock.launch(i) and el.__vsDock.running(i, bool) drive it from code.\n  Reduced motion: magnification follows instantly, no hop.\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=Inter+Tight:wght@400;500&display=swap\" rel=\"stylesheet\">\n<nav class=\"vs-dock\" aria-label=\"Dock\" data-magnify=\"1.9\" data-range=\"3\">\n  <ul class=\"vs-dock-list\">\n    <li class=\"vs-dock-item\" data-running><button class=\"vs-dock-btn\" aria-label=\"Relay\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(165deg,#5fe3d2,#1a86c2)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><rect x=\"36\" y=\"22\" width=\"44\" height=\"32\" rx=\"16\" fill=\"#fff\" opacity=\".42\"/><path d=\"M38 36h14a17 17 0 0 1 0 34H40l-12 9 3-11.5A17 17 0 0 1 38 36z\" fill=\"#fff\"/></svg></span></button></li>\n    <li class=\"vs-dock-item\" data-hide-sm><button class=\"vs-dock-btn\" aria-label=\"Almanac\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(170deg,#fffefb,#e9e4da)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><g fill=\"#c9c1b3\"><circle cx=\"30\" cy=\"32\" r=\"5\"/><circle cx=\"50\" cy=\"32\" r=\"5\"/><circle cx=\"70\" cy=\"32\" r=\"5\"/><circle cx=\"30\" cy=\"52\" r=\"5\"/><circle cx=\"50\" cy=\"52\" r=\"5\"/><circle cx=\"30\" cy=\"72\" r=\"5\"/><circle cx=\"50\" cy=\"72\" r=\"5\"/><circle cx=\"70\" cy=\"72\" r=\"5\"/></g><circle cx=\"70\" cy=\"52\" r=\"10\" fill=\"#ff5a3c\"/><circle cx=\"67\" cy=\"49\" r=\"3\" fill=\"#fff\" opacity=\".45\"/></svg></span></button></li>\n    <li class=\"vs-dock-item\" data-hide-sm><button class=\"vs-dock-btn\" aria-label=\"Folio\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(170deg,#fff6dc,#f2d58c)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><path d=\"M64 0h16v30l-8-6-8 6z\" fill=\"#ff6b45\"/><g stroke=\"#8c6a26\" stroke-width=\"5\" stroke-linecap=\"round\" opacity=\".5\"><path d=\"M24 46h52M24 60h52M24 74h32\"/></g></svg></span></button></li>\n    <li class=\"vs-dock-item\" data-running><button class=\"vs-dock-btn\" aria-label=\"Lumen\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(165deg,#283056,#0d0f21)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><g style=\"mix-blend-mode:screen\"><circle cx=\"40\" cy=\"42\" r=\"19\" fill=\"#ff5f57\" opacity=\".9\"/><circle cx=\"60\" cy=\"42\" r=\"19\" fill=\"#ffc24a\" opacity=\".9\"/><circle cx=\"50\" cy=\"60\" r=\"19\" fill=\"#3fc8e8\" opacity=\".9\"/></g><circle cx=\"50\" cy=\"48\" r=\"4\" fill=\"#fff\" opacity=\".9\"/></svg></span></button></li>\n    <li class=\"vs-dock-item\" data-hide-sm><button class=\"vs-dock-btn\" aria-label=\"Wayfinder\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(165deg,#e9f1de,#b3cca4)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><g fill=\"#fff\" opacity=\".45\"><rect x=\"12\" y=\"12\" width=\"26\" height=\"22\" rx=\"4\"/><rect x=\"62\" y=\"62\" width=\"28\" height=\"26\" rx=\"4\"/><rect x=\"60\" y=\"14\" width=\"28\" height=\"18\" rx=\"4\"/></g><path d=\"M-4 78C24 78 26 52 50 50s28-26 56-30\" fill=\"none\" stroke=\"#fff\" stroke-width=\"11\" stroke-linecap=\"round\"/><path d=\"M-4 78C24 78 26 52 50 50s28-26 56-30\" fill=\"none\" stroke=\"#9db88f\" stroke-width=\"2\" stroke-dasharray=\"5 6\"/><circle cx=\"50\" cy=\"50\" r=\"9\" fill=\"#ff5a3c\"/><circle cx=\"50\" cy=\"50\" r=\"3.4\" fill=\"#fff\"/></svg></span></button></li>\n    <li class=\"vs-dock-item\"><button class=\"vs-dock-btn\" aria-label=\"Tidepool\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(160deg,#ffa27c,#e3405e)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><g fill=\"none\" stroke=\"#fff\" stroke-width=\"6\" stroke-linecap=\"round\"><path d=\"M18 36c8-7 16-7 24 0s16 7 24 0 16-7 16-7\" opacity=\".42\"/><path d=\"M18 52c8-7 16-7 24 0s16 7 24 0 16-7 16-7\"/><path d=\"M18 68c8-7 16-7 24 0s16 7 24 0 16-7 16-7\" opacity=\".68\"/></g></svg></span></button></li>\n    <li class=\"vs-dock-item\" data-hide-sm><button class=\"vs-dock-btn\" aria-label=\"Ledger\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(165deg,#30333a,#111215)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><defs><linearGradient id=\"vs-dock-lg\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"#dcff72\"/><stop offset=\"1\" stop-color=\"#47d98a\"/></linearGradient></defs><path d=\"M20 76h60\" stroke=\"#fff\" stroke-opacity=\".18\" stroke-width=\"2\"/><g fill=\"url(#vs-dock-lg)\"><rect x=\"22\" y=\"54\" width=\"11\" height=\"18\" rx=\"3\"/><rect x=\"38\" y=\"44\" width=\"11\" height=\"28\" rx=\"3\"/><rect x=\"54\" y=\"49\" width=\"11\" height=\"23\" rx=\"3\"/><rect x=\"70\" y=\"28\" width=\"11\" height=\"44\" rx=\"3\"/></g></svg></span></button></li>\n    <li class=\"vs-dock-item\" data-running><button class=\"vs-dock-btn\" aria-label=\"Kiln\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(165deg,#373a42,#16181c)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><g fill=\"#fff\" opacity=\".22\"><circle cx=\"20\" cy=\"18\" r=\"3.2\"/><circle cx=\"30\" cy=\"18\" r=\"3.2\"/><circle cx=\"40\" cy=\"18\" r=\"3.2\"/></g><g fill=\"none\" stroke=\"#9df2c9\" stroke-width=\"6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M26 40l14 12-14 12\"/><path d=\"M50 66h24\"/></g></svg></span></button></li>\n    <li class=\"vs-dock-item\" data-hide-sm><button class=\"vs-dock-btn\" aria-label=\"Tuner\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(165deg,#f5f6f8,#b0b5be)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><g stroke=\"#3a3f48\" stroke-width=\"4.5\" stroke-linecap=\"round\" opacity=\".32\"><path d=\"M24 32h52M24 50h52M24 68h52\"/></g><g stroke=\"#3a3f48\" stroke-width=\"3.2\"><circle cx=\"62\" cy=\"32\" r=\"7.5\" fill=\"#fff\"/><circle cx=\"54\" cy=\"68\" r=\"7.5\" fill=\"#fff\"/></g><circle cx=\"36\" cy=\"50\" r=\"8.6\" fill=\"#ff5a3c\"/><circle cx=\"34\" cy=\"47.5\" r=\"2.6\" fill=\"#fff\" opacity=\".45\"/></svg></span></button></li>\n    <li class=\"vs-dock-sep\"></li>\n    <li class=\"vs-dock-item\"><button class=\"vs-dock-btn\" aria-label=\"Bin\"><span class=\"vs-dock-tile\" style=\"--g:linear-gradient(170deg,#fafaf8,#d4d6da)\"><svg viewBox=\"0 0 100 100\" aria-hidden=\"true\"><g fill=\"none\" stroke=\"#7a808b\" stroke-width=\"3.4\" stroke-linejoin=\"round\" stroke-linecap=\"round\"><path d=\"M28 32h44l-5 44H33z\" fill=\"#fff\" fill-opacity=\".6\"/><path d=\"M24 32h52\"/><path d=\"M42 40l1 28M58 40l-1 28M50 40v28\" opacity=\".6\"/></g></svg></span></button></li>\n  </ul>\n</nav>\n\n<style>\n.vs-dock {\n  --vs-dock-size: 64px;                          /* tile size at rest (data-size overrides) */\n  --vs-dock-shelf: rgba(255,255,255,.36);        /* frosted shelf fill */\n  --vs-dock-shelf-line: rgba(255,255,255,.66);   /* shelf hairline */\n  --vs-dock-shelf-edge: rgba(80,48,24,.10);      /* outer hairline, reads on light grounds */\n  --vs-dock-shadow: 70,38,16;                    /* rgb of the shelf's drop shadow */\n  --vs-dock-tip-bg: rgba(252,250,247,.86);       /* tooltip label */\n  --vs-dock-tip-ink: #1f1a15;\n  --vs-dock-dot: rgba(38,26,18,.74);             /* running indicator */\n  --vs-dock-focus: #1f6bff;\n  --vs-dock-font: \"Inter Tight\", ui-sans-serif, -apple-system, system-ui, sans-serif;\n\n  /* written by the script: --t (tile px), --tm (tile px at full magnification), --pad */\n  --t: 64px; --tm: 122px; --pad: 10px;\n  position: relative; display: block; margin: 0 auto; flex: none; max-width: none;\n  touch-action: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;\n  font-family: var(--vs-dock-font);\n}\n.vs-dock, .vs-dock * { box-sizing: border-box; }\n.vs-dock--dark {\n  --vs-dock-shelf: rgba(30,24,22,.5);\n  --vs-dock-shelf-line: rgba(255,255,255,.16);\n  --vs-dock-shelf-edge: rgba(0,0,0,.28);\n  --vs-dock-shadow: 20,10,4;\n  --vs-dock-tip-bg: rgba(30,27,25,.84);\n  --vs-dock-tip-ink: #f6f1ea;\n  --vs-dock-dot: rgba(255,248,240,.82);\n}\n.vs-dock-shelf {\n  position: absolute; left: 0; bottom: 0; height: 100%; width: 100%;\n  border-radius: calc(var(--t) * .36); pointer-events: none; contain: strict;\n  background:\n    linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,0) 46%),\n    var(--vs-dock-shelf);\n  -webkit-backdrop-filter: blur(22px) saturate(1.7); backdrop-filter: blur(22px) saturate(1.7);\n  box-shadow:\n    inset 0 0 0 .5px var(--vs-dock-shelf-line),\n    inset 0 1px 0 rgba(255,255,255,.55),\n    0 0 0 .5px var(--vs-dock-shelf-edge),\n    0 1px 2px rgba(var(--vs-dock-shadow),.08),\n    0 10px 24px -8px rgba(var(--vs-dock-shadow),.26),\n    0 30px 60px -24px rgba(var(--vs-dock-shadow),.30);\n  will-change: transform, width;\n}\n.vs-dock--dark .vs-dock-shelf { background: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,0) 50%), var(--vs-dock-shelf); }\n.vs-dock-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }\n.vs-dock-item, .vs-dock-sep {\n  position: absolute; left: 0; bottom: var(--pad); width: var(--tm); height: var(--tm);\n  pointer-events: none; will-change: transform;\n}\n.vs-dock-sep { width: 0; height: var(--t); }\n.vs-dock-sep::before {\n  content: \"\"; position: absolute; left: -.5px; top: 12%; bottom: 12%; width: 1px;\n  background: var(--vs-dock-shelf-line); box-shadow: 1px 0 0 var(--vs-dock-shelf-edge); opacity: .9;\n}\n.vs-dock-btn {\n  all: unset; box-sizing: border-box; position: absolute; left: 0; bottom: 0; width: var(--tm); height: var(--tm);\n  font-size: var(--tm); cursor: pointer; pointer-events: auto; border-radius: .225em;\n  transform-origin: 50% 100%; will-change: transform; -webkit-tap-highlight-color: transparent;\n}\n.vs-dock-tile {\n  position: absolute; inset: 0; border-radius: .225em; overflow: hidden; isolation: isolate;\n  background: var(--g, linear-gradient(165deg,#f2f2f0,#c9cbd0));\n  box-shadow:\n    inset 0 .012em 0 rgba(255,255,255,.55),\n    inset 0 -.014em .02em rgba(0,0,0,.16),\n    0 0 0 .006em rgba(0,0,0,.14),\n    0 .012em .024em rgba(0,0,0,.14),\n    0 .06em .12em -.03em rgba(40,20,8,.30);\n}\n.vs-dock-tile::before {                            /* top gloss */\n  content: \"\"; position: absolute; inset: 0; z-index: 2; pointer-events: none; border-radius: inherit;\n  background: radial-gradient(120% 64% at 50% -8%, rgba(255,255,255,.34), rgba(255,255,255,0) 62%);\n}\n.vs-dock-tile::after {                             /* inner rim */\n  content: \"\"; position: absolute; inset: 0; z-index: 3; pointer-events: none; border-radius: inherit;\n  box-shadow: inset 0 0 0 .006em rgba(255,255,255,.22);\n}\n.vs-dock-tile svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 1; }\n.vs-dock-shade { position: absolute; inset: 0; z-index: 4; background: #000; opacity: 0; pointer-events: none; }\n.vs-dock-btn:focus-visible .vs-dock-tile {\n  box-shadow: inset 0 .012em 0 rgba(255,255,255,.55), 0 0 0 .025em #fff, 0 0 0 .05em var(--vs-dock-focus), 0 .06em .12em -.03em rgba(40,20,8,.3);\n}\n.vs-dock-btn[aria-disabled=\"true\"] { cursor: not-allowed; }\n.vs-dock-btn[aria-disabled=\"true\"] .vs-dock-tile { opacity: .45; }\n.vs-dock-dot {\n  position: absolute; left: 50%; bottom: calc(var(--pad) * -.62); width: 4px; height: 4px; margin: -2px 0 0 -2px;\n  border-radius: 50%; background: var(--vs-dock-dot); opacity: 0; pointer-events: none;\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.25);\n}\n.vs-dock-tip {\n  position: absolute; left: 50%; bottom: 0; padding: 6px 10px 6px; border-radius: 8px; white-space: nowrap;\n  font: 500 13px/1 var(--vs-dock-font); letter-spacing: -.01em; color: var(--vs-dock-tip-ink);\n  background: var(--vs-dock-tip-bg); -webkit-backdrop-filter: blur(14px) saturate(1.6); backdrop-filter: blur(14px) saturate(1.6);\n  box-shadow: inset 0 0 0 .5px rgba(255,255,255,.5), 0 0 0 .5px rgba(0,0,0,.08), 0 6px 16px -6px rgba(40,20,8,.35), 0 1px 3px rgba(40,20,8,.12);\n  opacity: 0; pointer-events: none; transform-origin: 50% 100%; will-change: transform, opacity;\n}\n.vs-dock--dark .vs-dock-tip { box-shadow: inset 0 0 0 .5px rgba(255,255,255,.12), 0 0 0 .5px rgba(0,0,0,.4), 0 8px 18px -6px rgba(0,0,0,.5); }\n@media (forced-colors: active) { .vs-dock-shelf { border: 1px solid CanvasText; } .vs-dock-tip { border: 1px solid CanvasText; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsDock) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  // one shared rAF: runs only while something 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 d of live) { let keep = false; try { keep = d.update(dt); } catch (e) { console.error(e); } if (!keep) live.delete(d); }\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = d => { live.add(d); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  // semi-implicit spring: k stiffness, z damping ratio\n  const S = (x, k, z) => ({ x, v: 0, g: x, k, 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\n  function mount(dock) {\n    if (dock.__vsDock) return dock.__vsDock;\n    const list = dock.querySelector('.vs-dock-list');\n    let shelf = dock.querySelector('.vs-dock-shelf');\n    if (!shelf) { shelf = document.createElement('div'); shelf.className = 'vs-dock-shelf'; shelf.setAttribute('aria-hidden', 'true'); dock.prepend(shelf); }\n    list.setAttribute('role', 'toolbar');\n    if (!list.hasAttribute('aria-label')) list.setAttribute('aria-label', dock.getAttribute('aria-label') || 'Dock');\n\n    let items = [], apps = [];\n    function collect() {\n      items = [...list.children].filter(li => !li.hidden && getComputedStyle(li).display !== 'none').map(li => {\n        if (li.__vsd) return li.__vsd;\n        const sep = li.classList.contains('vs-dock-sep');\n        const d = { li, sep, s: 1 };\n        if (sep) { li.setAttribute('role', 'separator'); li.setAttribute('aria-orientation', 'vertical'); }\n        else {\n          const btn = d.btn = li.querySelector('.vs-dock-btn');\n          const label = btn.dataset.label || btn.getAttribute('aria-label') || btn.textContent.trim();\n          btn.setAttribute('aria-label', label);\n          if (btn.tagName === 'BUTTON') btn.type = 'button';\n          d.tile = btn.querySelector('.vs-dock-tile');\n          d.shade = Object.assign(document.createElement('span'), { className: 'vs-dock-shade' }); d.tile.append(d.shade);\n          d.tip = Object.assign(document.createElement('span'), { className: 'vs-dock-tip', textContent: label }); d.tip.setAttribute('aria-hidden', 'true');\n          d.dot = Object.assign(document.createElement('span'), { className: 'vs-dock-dot' }); d.dot.setAttribute('aria-hidden', 'true');\n          li.append(d.tip, d.dot);\n          d.running = li.hasAttribute('data-running');\n          d.tipS = S(0, 460, .74); d.dotS = S(d.running ? 1 : 0, 320, .55); d.press = S(0, 900, .9); d.sq = S(0, 620, .3);\n          d.hop = { y: 0, v: 0, v0: 0, left: 0, air: false, launching: false };\n        }\n        li.__vsd = d; return d;\n      });\n      apps = items.filter(d => !d.sep);\n      const cur = apps.find(d => d.btn.tabIndex === 0) || apps.find(d => d.btn.getAttribute('aria-disabled') !== 'true') || apps[0];\n      apps.forEach(d => d.btn.tabIndex = d === cur ? 0 : -1);\n    }\n\n    // geometry\n    let T = 64, M = 1.9, R = 3, G = 9, P = 10, SW = 20, W0 = 0, restLeft = 0, GRAV = 2800, Lb = -1e5, Rb = 1e5;\n    function measure() {\n      collect();\n      if (!items.length) return;\n      const want = parseFloat(dock.dataset.size) || parseFloat(getComputedStyle(dock).getPropertyValue('--vs-dock-size')) || 64;\n      M = clamp(parseFloat(dock.dataset.magnify ?? 1.9) || 1, 1, 3.2);\n      R = clamp(parseFloat(dock.dataset.range ?? 3) || 3, .5, 8);\n      // unit layout (tile = 1) and the widest the dock can get when fully magnified\n      let x = .16; const cs = [];\n      for (const d of items) { const w = d.sep ? .32 : 1; if (!d.sep) cs.push(x + w / 2); x += w + .14; }\n      const units = x - .14 + .16; let F = 0;\n      for (const p of cs) { let f = 0; for (const c of cs) { const q = Math.abs(p - c) / (R * 1.14); if (q < 1) f += (1 + Math.cos(Math.PI * q)) / 2; } F = Math.max(F, f); }\n      const need = units + (M - 1) * F;\n      // room: the parent's content box, unless the parent is sized by the dock itself; then the viewport\n      const host = dock.parentElement || document.body, hs = getComputedStyle(host);\n      const pl = parseFloat(hs.paddingLeft) || 0, pr = parseFloat(hs.paddingRight) || 0;\n      const w1 = host.clientWidth; dock.style.width = '0px'; const wrap = host.clientWidth !== w1;\n      const hr = host.getBoundingClientRect();\n      let bl = hr.left + pl, br = hr.right - pr;\n      if (wrap || br - bl < 40) { bl = 16; br = innerWidth - 16; }\n      T = clamp(Math.min(want, (br - bl) / need), 16, want);\n      G = T * .14; P = T * .16; SW = T * .32; GRAV = 44 * T;\n      x = P;\n      for (const d of items) { const w = d.sep ? SW : T; d.r0 = x; d.rw = w; d.c = x + w / 2; x += w + G; }\n      W0 = x - G + P;\n      dock.style.width = W0 + 'px'; dock.style.height = (T + 2 * P) + 'px';\n      const dr = dock.getBoundingClientRect(); restLeft = 0;\n      Lb = bl - dr.left; Rb = br - dr.left;\n      dock.style.setProperty('--t', T + 'px'); dock.style.setProperty('--tm', (T * M) + 'px'); dock.style.setProperty('--pad', P + 'px');\n      layout(); wake(api);\n    }\n\n    const ptr = S(0, 520, .9), pres = S(0, 240, .92);\n    let rawX = -1e4, inside = false, touching = false, focusD = null, pressD = null, touchT = 0;\n    let lastO = 0;\n    const hoveredApp = () => {\n      if (focusD && !inside && !touching) return focusD;\n      if (!inside && !touching) return null;\n      const X = rawX + restLeft - lastO;                  // pointer in the expanded layout\n      for (const d of apps) if (X >= d.x - G / 2 && X <= d.x + d.w + G / 2) return d;\n      return null;\n    };\n\n    function layout() {\n      const m = Math.max(0, pres.x), p = ptr.x, span = R * (T + G), TM = T * M;\n      let x = P;\n      for (const d of items) {\n        let s = 1;\n        if (!d.sep && m > 0) { const q = Math.abs(p - d.c) / span; if (q < 1) s = 1 + (M - 1) * m * (1 + Math.cos(Math.PI * q)) / 2; }\n        d.s = s; d.x = x; d.w = d.sep ? SW : T * s; x += d.w + G;\n      }\n      const W = x - G + P;\n      // fixed point: the spot under the pointer stays under the pointer while everything else makes room\n      let mapped = p;\n      if (m > 0 && p > items[0].r0) {\n        mapped = W - (W0 - p);\n        for (const d of items) {\n          if (p < d.r0 + d.rw) { mapped = d.x + (p - d.r0) * (d.w / d.rw); break; }\n          if (p < d.r0 + d.rw + G) { mapped = d.x + d.w + (p - d.r0 - d.rw); break; }\n        }\n      }\n      let o = restLeft + p - mapped;\n      if (W <= Rb - Lb) o = clamp(o, Lb, Rb - W);             // never leave the container\n      lastO = o;\n      shelf.style.width = W.toFixed(2) + 'px';\n      shelf.style.transform = `translate3d(${o.toFixed(2)}px,0,0)`;\n      for (const d of items) {\n        const cx = o + d.x + d.w / 2;\n        if (d.sep) { d.li.style.transform = `translate3d(${cx.toFixed(2)}px,0,0)`; continue; }\n        d.li.style.transform = `translate3d(${(cx - TM / 2).toFixed(2)}px,0,0)`;\n        const hp = d.hop, air = hp.v0 ? clamp(Math.abs(hp.v) / hp.v0, 0, 1) * (hp.air ? .05 : 0) : 0;\n        const k = d.s / M * (1 - .055 * d.press.x);\n        const sy = k * (1 - .11 * d.sq.x + air), sx = k * (1 + .085 * d.sq.x - air * .6);\n        d.btn.style.transform = `translate3d(0,${(-hp.y).toFixed(2)}px,0) scale(${sx.toFixed(4)},${sy.toFixed(4)})`;\n        d.shade.style.opacity = (d.press.x * .2).toFixed(3);\n        const t = d.tipS.x;\n        d.tip.style.opacity = clamp(t, 0, 1).toFixed(3);\n        d.tip.style.transform = `translate3d(-50%,${(-(T * d.s) - hp.y - 12 - 8 * (1 - t)).toFixed(2)}px,0) scale(${(.86 + .14 * t).toFixed(4)})`;\n        const ds = d.dotS.x;\n        d.dot.style.opacity = clamp(ds, 0, 1).toFixed(3);\n        d.dot.style.transform = `scale(${Math.max(0, ds).toFixed(3)})`;\n      }\n    }\n\n    function launch(d) {\n      if (!d || d.btn.getAttribute('aria-disabled') === 'true') return;\n      const first = !d.running;\n      d.btn.dispatchEvent(new CustomEvent('launch', { bubbles: true, detail: { label: d.btn.getAttribute('aria-label'), running: d.running } }));\n      if (reduce.matches) { d.running = true; d.li.setAttribute('data-running', ''); d.dotS.g = 1; wake(api); return; }\n      const H = T * (first ? .56 : .3);\n      const hp = d.hop; hp.v0 = Math.sqrt(2 * GRAV * H); hp.v = hp.v0; hp.air = true; hp.left = first ? 1 : 0; hp.launching = first;\n      d.sq.v -= 5;                                   // stretch on take-off\n      wake(api);\n    }\n    function stepHop(d, n, h) {\n      const hp = d.hop; if (!hp.air) return false;\n      for (let i = 0; i < n; i++) {\n        hp.v -= GRAV * h; hp.y += hp.v * h;\n        if (hp.y <= 0 && hp.v < 0) {\n          const impact = -hp.v; hp.y = 0;\n          d.sq.v += 7 * impact / hp.v0;\n          if (hp.left > 0) { hp.left--; hp.v = hp.v0; }\n          else if (impact > hp.v0 * .4) hp.v = impact * .26;\n          else {\n            hp.v = 0; hp.air = false;\n            if (hp.launching) { hp.launching = false; d.running = true; d.li.setAttribute('data-running', ''); d.dotS.g = 1; }\n            break;\n          }\n        }\n      }\n      return hp.air;\n    }\n\n    const api = {\n      el: dock, refresh: measure, launch: i => launch(apps[i]),\n      running(i, on) { const d = apps[i]; if (!d) return; d.running = !!on; d.li.toggleAttribute('data-running', !!on); d.dotS.g = on ? 1 : 0; wake(api); },\n      update(dt) {\n        const n = Math.max(1, Math.round(dt * 120)), h = dt / n;\n        const hov = hoveredApp();\n        pres.g = (inside || touching || focusD) ? 1 : 0;\n        let moving = run(ptr, n, h, .02) | run(pres, n, h, .0005);\n        for (const d of apps) {\n          d.tipS.g = d === hov && pres.g ? 1 : 0;\n          d.press.g = d === pressD ? 1 : 0;\n          moving |= run(d.tipS, n, h, .002) | run(d.dotS, n, h, .002) | run(d.press, n, h, .002) | run(d.sq, n, h, .001) | stepHop(d, n, h);\n        }\n        layout();\n        return !!moving;\n      },\n    };\n\n    const toRest = e => e.clientX - dock.getBoundingClientRect().left - restLeft;\n    function aim(x) {\n      rawX = x;\n      if (pres.x < .04) { ptr.x = x; ptr.v = 0; }       // no sweep in from the last spot\n      ptr.g = x; wake(api);\n    }\n    dock.addEventListener('pointermove', e => {\n      if (e.pointerType === 'touch' && !touching) return;\n      if (e.pointerType !== 'touch') inside = true;\n      aim(toRest(e));\n      if (touching) pressD = hoveredApp();\n    });\n    dock.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch') { inside = false; pressD = null; wake(api); } });\n    dock.addEventListener('pointerdown', e => {\n      if (e.button > 0) return;\n      if (e.pointerType === 'touch') { touching = true; try { dock.setPointerCapture(e.pointerId); } catch (_) {} }\n      else inside = true;\n      aim(toRest(e));\n      pressD = hoveredApp();\n      if (e.pointerType === 'touch' && navigator.vibrate) try { navigator.vibrate(6); } catch (_) {}\n    });\n    const up = e => {\n      const d = pressD; pressD = null;\n      if (touching) {\n        touching = false;\n        if (e.type === 'pointerup' && d) { touchT = performance.now(); launch(d); }\n      }\n      wake(api);\n    };\n    dock.addEventListener('pointerup', up); dock.addEventListener('pointercancel', up);\n    list.addEventListener('click', e => {\n      const btn = e.target.closest('.vs-dock-btn'); if (!btn) return;\n      if (performance.now() - touchT < 600) return;       // already launched by the touch release\n      launch(btn.parentElement.__vsd);\n    });\n    // keyboard: roving tabindex across the toolbar, focus magnifies\n    list.addEventListener('keydown', e => {\n      const i = apps.findIndex(d => d.btn === document.activeElement); if (i < 0) return;\n      const ok = apps.filter(d => d.btn.getAttribute('aria-disabled') !== 'true');\n      const j = ok.indexOf(apps[i]); let t = null;\n      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') t = ok[(j + 1) % ok.length];\n      else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') t = ok[(j - 1 + ok.length) % ok.length];\n      else if (e.key === 'Home') t = ok[0];\n      else if (e.key === 'End') t = ok[ok.length - 1];\n      if (!t) return;\n      e.preventDefault(); apps.forEach(d => d.btn.tabIndex = d === t ? 0 : -1); t.btn.focus();\n    });\n    list.addEventListener('focusin', e => {\n      const btn = e.target.closest('.vs-dock-btn'); if (!btn) return;\n      apps.forEach(d => d.btn.tabIndex = d.btn === btn ? 0 : -1);\n      if (!btn.matches(':focus-visible')) return;\n      focusD = btn.parentElement.__vsd;\n      if (!inside) aim(focusD.c);\n    });\n    list.addEventListener('focusout', e => { if (!list.contains(e.relatedTarget)) { focusD = null; wake(api); } });\n\n    const ro = new ResizeObserver(() => measure()); ro.observe(dock.parentElement || document.body); addEventListener('resize', measure);\n    new MutationObserver(measure).observe(dock, { attributes: true, attributeFilter: ['data-magnify', 'data-range', 'data-size', 'class'] });\n    new MutationObserver(() => { items.forEach(d => { if (!d.li.isConnected) delete d.li.__vsd; }); measure(); }).observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden'] });\n    if (document.fonts) document.fonts.ready.then(measure);\n    dock.__vsDock = api; measure();\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-dock').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsDock = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Navigation",
    "tags": [
      "dock",
      "magnification",
      "spring",
      "toolbar",
      "app icons",
      "tooltip",
      "touch",
      "keyboard"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#ff7a52",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/dock/index.html",
    "thumbnail": "https://vividsites.app/api/component/dock/thumb.jpg",
    "video": "https://vividsites.app/api/component/dock/thumb.mp4",
    "page": "https://vividsites.app/c/dock"
  }
}