{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mobile-sheet",
  "type": "registry:block",
  "title": "Mobile Sheet Menu",
  "description": "A phone menu in a bottom sheet you can drag, fling and snap, with rubber-banding at the top, a backdrop that dims with the drag and a page behind that recedes like iOS. Links rise in on a stagger each time it opens.",
  "docs": "Mobile Sheet Menu was added to public/vividsites/mobile-sheet.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/mobile-sheet",
  "categories": [
    "ui",
    "navigation"
  ],
  "files": [
    {
      "path": "components/vividsites/mobile-sheet.html",
      "type": "registry:file",
      "target": "public/vividsites/mobile-sheet.html",
      "content": "<!--\nvs-mobile-sheet: a phone menu as a bottom sheet. Drag it anywhere: it follows your finger,\n  rubber-bands past the top, and on release its velocity is projected forward to pick the\n  snap point (or dismiss it on a firm flick down); the spring carries that velocity, so a\n  grab mid-animation just catches it. The backdrop dims in step with the drag, the page\n  behind recedes (scale + rounded corners, like iOS), and the links rise in on a stagger\n  every time it opens. Tap the handle to step between snaps. Modal dialog with a focus\n  trap; Escape, the backdrop and the close button dismiss it, and focus returns to the\n  trigger. Link taps close it too.\n\n  Markup:  <button class=\"vs-mobile-sheet-trigger\" aria-controls=\"vs-mobile-sheet-menu\">...</button>\n           <div class=\"vs-mobile-sheet\" id=\"vs-mobile-sheet-menu\" data-snaps=\"nav,1\" hidden>\n             <div class=\"vs-mobile-sheet-backdrop\"></div>\n             <div class=\"vs-mobile-sheet-panel\"> handle, head, nav of .vs-mobile-sheet-link, .vs-mobile-sheet-more </div>\n           </div>\n  For the background-scale effect, mark your page wrapper: <div data-vs-mobile-sheet-wrapper>\n  (or point data-wrapper=\"<selector>\" at it) and give <body> a dark background.\n  Attributes on .vs-mobile-sheet:\n    data-snaps          visible heights: fractions of the screen (<= 1), px, or \"nav\" (just the links),\n                        e.g. \"nav,1\" or \"0.5,1\". 1 means all of the content. Omit it for one snap at content height.\n    data-scale=\"false\"  turn the background-scale effect off\n    data-open           start open\n    data-close-on-nav=\"false\"  keep it open when a link is tapped\n  Modifier: .vs-mobile-sheet--dark for a dark sheet (put it on the trigger too to match).\n  CSS variables: --vs-mobile-sheet-accent, --vs-mobile-sheet-on-accent, --vs-mobile-sheet-bg,\n    --vs-mobile-sheet-ink, --vs-mobile-sheet-line, --vs-mobile-sheet-dim (rgb), --vs-mobile-sheet-radius,\n    --vs-mobile-sheet-font.\n  Events on .vs-mobile-sheet: \"open\", \"close\", \"snap\" (detail.index).\n  JS: el.__vsSheet.open() / .close() / .snap(i); vsMobileSheet.mount(el) for sheets added later.\n  Reduced motion: no springs or stagger, it simply appears.\n-->\n<link rel=\"preconnect\" href=\"https://api.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap\" rel=\"stylesheet\">\n<button class=\"vs-mobile-sheet-trigger\" type=\"button\" aria-controls=\"vs-mobile-sheet-menu\" aria-label=\"Open menu\"><span></span><span></span></button>\n\n<div class=\"vs-mobile-sheet\" id=\"vs-mobile-sheet-menu\" data-snaps=\"nav,1\" hidden>\n  <div class=\"vs-mobile-sheet-backdrop\"></div>\n  <div class=\"vs-mobile-sheet-panel\" aria-label=\"Menu\">\n    <button class=\"vs-mobile-sheet-handle\" type=\"button\" aria-label=\"Resize menu\"></button>\n    <div class=\"vs-mobile-sheet-head\"><span class=\"vs-mobile-sheet-label\">Menu</span><button class=\"vs-mobile-sheet-close\" type=\"button\" aria-label=\"Close menu\"><svg viewBox=\"0 0 14 14\" aria-hidden=\"true\"><path d=\"M2.5 2.5l9 9M11.5 2.5l-9 9\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"/></svg></button></div>\n    <nav class=\"vs-mobile-sheet-nav\" aria-label=\"Main\">\n      <a class=\"vs-mobile-sheet-link\" href=\"#work\" aria-current=\"page\"><span class=\"vs-mobile-sheet-idx\">01</span><span class=\"vs-mobile-sheet-text\">Work</span><svg class=\"vs-mobile-sheet-arrow\" viewBox=\"0 0 18 18\" aria-hidden=\"true\"><path d=\"M5 13 13 5M6.5 5H13v6.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></a>\n      <a class=\"vs-mobile-sheet-link\" href=\"#studio\"><span class=\"vs-mobile-sheet-idx\">02</span><span class=\"vs-mobile-sheet-text\">Studio</span><svg class=\"vs-mobile-sheet-arrow\" viewBox=\"0 0 18 18\" aria-hidden=\"true\"><path d=\"M5 13 13 5M6.5 5H13v6.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></a>\n      <a class=\"vs-mobile-sheet-link\" href=\"#journal\"><span class=\"vs-mobile-sheet-idx\">03</span><span class=\"vs-mobile-sheet-text\">Journal</span><svg class=\"vs-mobile-sheet-arrow\" viewBox=\"0 0 18 18\" aria-hidden=\"true\"><path d=\"M5 13 13 5M6.5 5H13v6.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></a>\n      <a class=\"vs-mobile-sheet-link\" href=\"#services\"><span class=\"vs-mobile-sheet-idx\">04</span><span class=\"vs-mobile-sheet-text\">Services</span><svg class=\"vs-mobile-sheet-arrow\" viewBox=\"0 0 18 18\" aria-hidden=\"true\"><path d=\"M5 13 13 5M6.5 5H13v6.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></a>\n      <a class=\"vs-mobile-sheet-link\" href=\"#contact\"><span class=\"vs-mobile-sheet-idx\">05</span><span class=\"vs-mobile-sheet-text\">Contact</span><svg class=\"vs-mobile-sheet-arrow\" viewBox=\"0 0 18 18\" aria-hidden=\"true\"><path d=\"M5 13 13 5M6.5 5H13v6.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></a>\n    </nav>\n    <div class=\"vs-mobile-sheet-more\">\n      <a class=\"vs-mobile-sheet-cta\" href=\"#start\">Start a project<i><svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M3 8h10M9 4l4 4-4 4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></i></a>\n      <div class=\"vs-mobile-sheet-minor\"><a href=\"#careers\">Careers</a><a href=\"#press\">Press</a><a href=\"#privacy\">Privacy</a></div>\n    </div>\n  </div>\n</div>\n\n<style>\n.vs-mobile-sheet, .vs-mobile-sheet-trigger {\n  --vs-mobile-sheet-accent: #2f8a5b;          /* CTA fill, current-page dot */\n  --vs-mobile-sheet-on-accent: #f6fbf7;\n  --vs-mobile-sheet-bg: #f7f5f0;              /* the sheet */\n  --vs-mobile-sheet-ink: #15170f;\n  --vs-mobile-sheet-line: rgba(21,23,15,.09);\n  --vs-mobile-sheet-dim: 0,0,0;               /* backdrop rgb */\n  --vs-mobile-sheet-radius: 28px;\n  --vs-mobile-sheet-font: \"General Sans\", ui-sans-serif, -apple-system, system-ui, sans-serif;\n  --vs-mobile-sheet-pad: 160px;               /* hidden overshoot below the screen edge */\n}\n.vs-mobile-sheet--dark, .vs-mobile-sheet-trigger.vs-mobile-sheet--dark {\n  --vs-mobile-sheet-bg: #141615; --vs-mobile-sheet-ink: #efeee8; --vs-mobile-sheet-line: rgba(255,255,255,.085);\n}\n.vs-mobile-sheet { position: fixed; inset: 0; z-index: 1000; overflow: hidden; font-family: var(--vs-mobile-sheet-font); -webkit-font-smoothing: antialiased; }\n.vs-mobile-sheet[hidden] { display: none; }\n.vs-mobile-sheet *, .vs-mobile-sheet *::before, .vs-mobile-sheet *::after { box-sizing: border-box; }\n.vs-mobile-sheet-backdrop { position: absolute; inset: 0; background: rgb(var(--vs-mobile-sheet-dim)); opacity: 0; will-change: opacity; -webkit-tap-highlight-color: transparent; }\n.vs-mobile-sheet-panel {\n  position: absolute; left: 0; right: 0; bottom: calc(-1 * var(--vs-mobile-sheet-pad)); margin: 0 auto; max-width: 560px;\n  padding: 0 20px calc(var(--vs-mobile-sheet-pad) + 16px + env(safe-area-inset-bottom, 0px));\n  border-radius: var(--vs-mobile-sheet-radius) var(--vs-mobile-sheet-radius) 0 0; outline: none;\n  color: var(--vs-mobile-sheet-ink); background: var(--vs-mobile-sheet-bg);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.85), inset 0 0 0 .5px rgba(0,0,0,.05),\n              0 -1px 3px rgba(0,0,0,.05), 0 -18px 48px -12px rgba(0,0,0,.28);\n  touch-action: none; user-select: none; -webkit-user-select: none; will-change: transform;\n  transform: translate3d(0, 110%, 0);\n}\n.vs-mobile-sheet--dark .vs-mobile-sheet-panel { box-shadow: inset 0 1px 0 rgba(255,255,255,.08), inset 0 0 0 .5px rgba(255,255,255,.06), 0 -18px 48px -12px rgba(0,0,0,.6); }\n.vs-mobile-sheet-handle {\n  all: unset; box-sizing: border-box; display: block; width: 100%; height: 26px; cursor: grab; position: relative; border-radius: 12px;\n}\n.vs-mobile-sheet-handle::before {\n  content: \"\"; position: absolute; left: 50%; top: 10px; width: 38px; height: 5px; margin-left: -19px; border-radius: 3px;\n  background: color-mix(in srgb, var(--vs-mobile-sheet-ink) 20%, transparent);\n  transform: scaleX(var(--grab, 1)); transition: transform .3s cubic-bezier(.3,1.5,.5,1), background .2s;\n}\n.vs-mobile-sheet.is-dragging .vs-mobile-sheet-handle::before { --grab: 1.22; background: color-mix(in srgb, var(--vs-mobile-sheet-ink) 34%, transparent); }\n.vs-mobile-sheet.is-dragging .vs-mobile-sheet-handle { cursor: grabbing; }\n.vs-mobile-sheet-handle:focus-visible::before { box-shadow: 0 0 0 3px var(--vs-mobile-sheet-bg), 0 0 0 4.5px var(--vs-mobile-sheet-accent); }\n.vs-mobile-sheet-head { display: flex; align-items: center; justify-content: space-between; height: 44px; margin: -2px 0 6px; }\n.vs-mobile-sheet-label { font-size: 12px; font-weight: 500; letter-spacing: .16em; text-transform: uppercase; color: color-mix(in srgb, var(--vs-mobile-sheet-ink) 46%, transparent); }\n.vs-mobile-sheet-close {\n  all: unset; box-sizing: border-box; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n  color: var(--vs-mobile-sheet-ink); background: color-mix(in srgb, var(--vs-mobile-sheet-ink) 6%, transparent);\n  transition: background .15s cubic-bezier(.2,0,0,1), transform .25s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-mobile-sheet-close:hover { background: color-mix(in srgb, var(--vs-mobile-sheet-ink) 10%, transparent); }\n.vs-mobile-sheet-close:active { transform: scale(.9); }\n.vs-mobile-sheet-close:focus-visible { box-shadow: 0 0 0 2px var(--vs-mobile-sheet-bg), 0 0 0 3.5px var(--vs-mobile-sheet-accent); }\n.vs-mobile-sheet-close svg { width: 14px; height: 14px; }\n.vs-mobile-sheet-nav { display: grid; }\n.vs-mobile-sheet-link {\n  position: relative; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; height: 64px; padding: 0 4px;\n  color: inherit; text-decoration: none; font-size: 34px; font-weight: 500; letter-spacing: -.035em; line-height: 1;\n  border-radius: 14px; -webkit-tap-highlight-color: transparent; outline: none;\n  box-shadow: 0 .5px 0 var(--vs-mobile-sheet-line); will-change: transform, opacity;\n}\n.vs-mobile-sheet-link:last-child { box-shadow: none; }\n.vs-mobile-sheet-link::before {\n  content: \"\"; position: absolute; inset: 4px -8px; border-radius: 14px; z-index: -1;\n  background: color-mix(in srgb, var(--vs-mobile-sheet-ink) 5%, transparent); opacity: 0; transform: scale(.97);\n  transition: opacity .15s cubic-bezier(.2,0,0,1), transform .3s cubic-bezier(.3,1.4,.5,1);\n}\n.vs-mobile-sheet-nav { position: relative; z-index: 0; }\n.vs-mobile-sheet-link:hover::before { opacity: .6; transform: none; }\n.vs-mobile-sheet-link:active::before { opacity: 1; transform: none; }\n.vs-mobile-sheet-link:focus-visible::before { opacity: 1; transform: none; box-shadow: inset 0 0 0 1.5px var(--vs-mobile-sheet-accent); }\n.vs-mobile-sheet-idx { font-size: 11px; font-weight: 500; letter-spacing: .04em; font-variant-numeric: tabular-nums; align-self: start; margin-top: 18px; color: color-mix(in srgb, var(--vs-mobile-sheet-ink) 40%, transparent); }\n.vs-mobile-sheet-text { display: inline-flex; align-items: center; gap: 12px; transition: transform .3s cubic-bezier(.3,1.4,.5,1); }\n.vs-mobile-sheet-link:hover .vs-mobile-sheet-text, .vs-mobile-sheet-link:active .vs-mobile-sheet-text { transform: translateX(4px); }\n.vs-mobile-sheet-link[aria-current=\"page\"] .vs-mobile-sheet-text::after {\n  content: \"\"; width: 8px; height: 8px; border-radius: 50%; background: var(--vs-mobile-sheet-accent);\n  box-shadow: 0 0 0 3px color-mix(in srgb, var(--vs-mobile-sheet-accent) 18%, transparent);\n}\n.vs-mobile-sheet-arrow { width: 18px; height: 18px; opacity: .28; transition: opacity .15s, transform .3s cubic-bezier(.3,1.4,.5,1); }\n.vs-mobile-sheet-link:hover .vs-mobile-sheet-arrow, .vs-mobile-sheet-link:active .vs-mobile-sheet-arrow { opacity: .9; transform: translate(2px,-2px); }\n.vs-mobile-sheet-more { display: grid; gap: 18px; padding-top: 22px; }\n.vs-mobile-sheet-cta {\n  display: flex; align-items: center; justify-content: space-between; height: 58px; padding: 0 10px 0 22px; border-radius: 999px;\n  color: var(--vs-mobile-sheet-on-accent); background: var(--vs-mobile-sheet-accent); text-decoration: none;\n  font-size: 17px; font-weight: 500; letter-spacing: -.01em; will-change: transform, opacity; -webkit-tap-highlight-color: transparent; outline: none;\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 -1px 0 rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.12), 0 10px 24px -10px color-mix(in srgb, var(--vs-mobile-sheet-accent) 80%, #000);\n}\n.vs-mobile-sheet-cta i { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: rgba(255,255,255,.16); transition: transform .3s cubic-bezier(.3,1.4,.5,1); }\n.vs-mobile-sheet-cta i svg { width: 16px; height: 16px; }\n.vs-mobile-sheet-cta:hover i { transform: translateX(3px); }\n.vs-mobile-sheet-cta:active { filter: brightness(.94); }\n.vs-mobile-sheet-cta:focus-visible { box-shadow: 0 0 0 2px var(--vs-mobile-sheet-bg), 0 0 0 4px var(--vs-mobile-sheet-accent); }\n.vs-mobile-sheet-minor { display: flex; gap: 22px; padding: 2px 4px 0; will-change: transform, opacity; }\n.vs-mobile-sheet-minor a { color: color-mix(in srgb, var(--vs-mobile-sheet-ink) 52%, transparent); text-decoration: none; font-size: 15px; font-weight: 500; outline: none; border-radius: 4px; transition: color .15s; }\n.vs-mobile-sheet-minor a:hover { color: var(--vs-mobile-sheet-ink); }\n.vs-mobile-sheet-minor a:focus-visible { box-shadow: 0 0 0 2px var(--vs-mobile-sheet-accent); }\n\n/* the trigger (lives in your page) */\n.vs-mobile-sheet-trigger {\n  all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; cursor: pointer;\n  background: var(--vs-mobile-sheet-bg); color: var(--vs-mobile-sheet-ink); -webkit-tap-highlight-color: transparent;\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 0 0 .5px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.08), 0 6px 16px -6px rgba(0,0,0,.22);\n  transition: transform .3s cubic-bezier(.3,1.5,.5,1);\n}\n.vs-mobile-sheet-trigger span { position: absolute; left: 14px; right: 14px; height: 1.6px; border-radius: 1px; background: currentColor; transition: transform .3s cubic-bezier(.3,1.5,.5,1); }\n.vs-mobile-sheet-trigger span:first-child { transform: translateY(-3.5px); }\n.vs-mobile-sheet-trigger span:last-child { transform: translateY(3.5px); }\n.vs-mobile-sheet-trigger:hover span:first-child { transform: translateY(-4.5px); }\n.vs-mobile-sheet-trigger:hover span:last-child { transform: translateY(4.5px) scaleX(.7); transform-origin: right; }\n.vs-mobile-sheet-trigger:active { transform: scale(.92); }\n.vs-mobile-sheet-trigger:focus-visible { box-shadow: 0 0 0 2px var(--vs-mobile-sheet-bg), 0 0 0 4px var(--vs-mobile-sheet-accent); }\n.vs-mobile-sheet-trigger:disabled { opacity: .4; cursor: not-allowed; }\n@media (prefers-reduced-motion: reduce) { .vs-mobile-sheet *, .vs-mobile-sheet-trigger, .vs-mobile-sheet-trigger span { transition: none !important; } }\n@media (forced-colors: active) { .vs-mobile-sheet-panel { border: 1px solid CanvasText; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsMobileSheet) 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 s of live) { let keep = false; try { keep = s.update(dt, now); } catch (e) { console.error(e); } if (!keep) live.delete(s); }\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = s => { live.add(s); 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  const rubber = (x, lim) => lim * (1 - Math.exp(-x / lim));     // x >= 0, approaches lim\n  const FOCUSABLE = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex=\"-1\"])';\n\n  function mount(root) {\n    if (root.__vsSheet) return root.__vsSheet;\n    const panel = root.querySelector('.vs-mobile-sheet-panel');\n    let backdrop = root.querySelector('.vs-mobile-sheet-backdrop');\n    if (!backdrop) { backdrop = document.createElement('div'); backdrop.className = 'vs-mobile-sheet-backdrop'; root.prepend(backdrop); }\n    const handle = root.querySelector('.vs-mobile-sheet-handle');\n    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.tabIndex = -1;\n    if (!panel.hasAttribute('aria-label')) panel.setAttribute('aria-label', 'Menu');\n    const triggers = () => root.id ? [...document.querySelectorAll(`[aria-controls=\"${root.id}\"]`)] : [];\n    const wrap = () => document.querySelector(root.dataset.wrapper || '[data-vs-mobile-sheet-wrapper]');\n    const items = [...root.querySelectorAll('.vs-mobile-sheet-link, .vs-mobile-sheet-more > *')].map(el => ({ el, s: S(0, 240, .78), at: 0 }));\n\n    let H = 0, maxH = 0, snaps = [0], cur = 0, open = false, back = null, drag = null, blockClick = 0;\n    const vis = S(0, 260, .9);\n    const PAD = () => parseFloat(getComputedStyle(root).getPropertyValue('--vs-mobile-sheet-pad')) || 160;\n\n    function measure() {\n      H = root.clientHeight; if (!H) return;\n      const pad = PAD();\n      panel.style.height = 'auto';\n      const content = panel.offsetHeight - pad;\n      const top = Math.max(28, H * .06);\n      maxH = Math.min(content, H - top);\n      panel.style.height = (maxH + pad) + 'px';\n      const raw = (root.dataset.snaps || '').split(',').map(s => s.trim()).filter(Boolean);\n      const nav = panel.querySelector('.vs-mobile-sheet-nav');\n      let list = raw.map(s => { if (s === 'nav' && nav) return nav.offsetTop + nav.offsetHeight + 14; const f = parseFloat(s); return f <= 1 ? f * H : f; }).filter(v => v > 0).map(v => clamp(v, Math.min(140, maxH), maxH));\n      if (!list.length) list = [maxH];\n      list.sort((a, b) => a - b); snaps = list.filter((v, i) => !i || v - list[i - 1] > 8);\n      cur = Math.min(cur, snaps.length - 1);\n      if (open && !drag) { vis.g = snaps[cur]; wake(api); }\n    }\n\n    function render() {\n      const v = vis.x, s0 = snaps[0] || 1;\n      panel.style.transform = `translate3d(0,${(maxH - v).toFixed(2)}px,0)`;\n      const p = clamp(v / s0, 0, 1), p2 = snaps.length > 1 ? clamp((v - s0) / (maxH - s0), 0, 1) : 0;\n      backdrop.style.opacity = (.42 * p + .1 * p2).toFixed(4);\n      const w = wrap();\n      if (w) {\n        if (root.dataset.scale !== 'false' && v > .5) {\n          w.style.transformOrigin = '50% 0'; w.style.borderRadius = '22px'; w.style.overflow = 'hidden';\n          w.style.transform = `translate3d(0,${(p * 26 + p2 * 6).toFixed(2)}px,0) scale(${(1 - .075 * p - .02 * p2).toFixed(4)})`;\n        } else { w.style.transform = ''; w.style.borderRadius = ''; w.style.overflow = ''; }\n      }\n    }\n\n    function show() {\n      if (open) return; open = true;\n      root.hidden = false; root.dataset.state = 'open'; measure();\n      if (vis.x < 1) { vis.x = 0; vis.v = 0; }\n      cur = 0; tune(vis, 250, .9); vis.g = snaps[0];\n      back = document.activeElement;\n      triggers().forEach(t => t.setAttribute('aria-expanded', 'true'));\n      const w = wrap(); if (w) w.inert = true;\n      const t0 = performance.now() + 70;\n      items.forEach((it, i) => { if (it.s.x < .02) { it.s.x = 0; it.s.v = 0; } it.at = t0 + i * 42; it.s.g = 1; });\n      requestAnimationFrame(() => panel.focus({ preventScroll: true }));\n      root.dispatchEvent(new CustomEvent('open', { bubbles: true }));\n      wake(api);\n    }\n    function hide(velocity = 0) {\n      if (!open) return; open = false;\n      root.dataset.state = 'closing';\n      tune(vis, 300, 1); vis.g = 0; vis.v = Math.min(vis.v, velocity);\n      triggers().forEach(t => t.setAttribute('aria-expanded', 'false'));\n      const w = wrap(); if (w) w.inert = false;\n      const to = back && back.isConnected ? back : triggers()[0];\n      if (to && root.contains(document.activeElement)) to.focus({ preventScroll: true });\n      root.dispatchEvent(new CustomEvent('close', { bubbles: true }));\n      wake(api);\n    }\n    function snapTo(i, velocity = 0) {\n      cur = clamp(i, 0, snaps.length - 1); tune(vis, 330, .8); vis.g = snaps[cur]; vis.v = velocity; wake(api);\n      root.dispatchEvent(new CustomEvent('snap', { bubbles: true, detail: { index: cur } }));\n    }\n\n    const api = {\n      el: root, open: show, close: () => hide(), snap: i => open && snapTo(i), get isOpen() { return open; },\n      update(dt, now) {\n        const n = Math.max(1, Math.round(dt * 120)), h = dt / n;\n        let moving = drag && drag.active ? true : run(vis, n, h, .2);\n        for (const it of items) {\n          if (open && now < it.at) { moving = true; continue; }\n          if (run(it.s, n, h, .001)) moving = true;\n          const p = it.s.x;\n          it.el.style.opacity = clamp(p, 0, 1).toFixed(3);\n          it.el.style.transform = p >= .999 ? '' : `translate3d(0,${((1 - p) * 26).toFixed(2)}px,0)`;\n        }\n        render();\n        if (!open && !moving && vis.x < .5) {                       // fully closed: tidy up\n          root.hidden = true; root.dataset.state = 'closed';\n          items.forEach(it => { it.s.x = it.s.g = 0; it.s.v = 0; it.el.style.opacity = '0'; });\n          const w = wrap(); if (w) { w.style.transform = ''; w.style.borderRadius = ''; w.style.overflow = ''; }\n        }\n        return moving;\n      },\n    };\n\n    // drag anywhere on the sheet, with velocity and a rubber band past the top\n    panel.addEventListener('pointerdown', e => {\n      if (!open || e.button > 0) return;\n      const r = panel.getBoundingClientRect();\n      drag = { id: e.pointerId, y0: e.clientY, v0: vis.x, ly: e.clientY, lt: performance.now(), vel: 0, active: false, k: r.height / panel.offsetHeight || 1 };\n    });\n    panel.addEventListener('pointermove', e => {\n      if (!drag || e.pointerId !== drag.id) return;\n      const dy = (e.clientY - drag.y0) / drag.k;\n      if (!drag.active) {\n        if (Math.abs(dy) < 6) return;\n        drag.active = true; drag.y0 = e.clientY; drag.v0 = vis.x; root.classList.add('is-dragging');\n        try { panel.setPointerCapture(e.pointerId); } catch (_) {}\n      }\n      const d = (e.clientY - drag.y0) / drag.k, raw = drag.v0 - d;\n      vis.x = raw > maxH ? maxH + rubber(raw - maxH, 56) : Math.max(0, raw); vis.v = 0; vis.g = vis.x;\n      const now = performance.now(), dtm = Math.max(1, now - drag.lt);\n      const inst = -((e.clientY - drag.ly) / drag.k) / (dtm / 1000);\n      drag.vel = drag.vel * .6 + inst * .4; drag.ly = e.clientY; drag.lt = now;\n      wake(api);\n    });\n    const end = e => {\n      if (!drag || e.pointerId !== drag.id) return;\n      const d = drag; drag = null; root.classList.remove('is-dragging');\n      if (!d.active) return;\n      blockClick = performance.now();\n      const vel = performance.now() - d.lt > 90 ? 0 : clamp(d.vel, -6000, 6000);   // a pause kills the flick\n      const proj = vis.x + vel * .2;\n      if (proj < snaps[0] * .55) { hide(vel); return; }\n      let best = 0; snaps.forEach((s, i) => { if (Math.abs(s - proj) < Math.abs(snaps[best] - proj)) best = i; });\n      snapTo(best, vel);\n    };\n    panel.addEventListener('pointerup', end); panel.addEventListener('pointercancel', end);\n    panel.addEventListener('click', e => {\n      if (performance.now() - blockClick < 250) { e.preventDefault(); e.stopPropagation(); return; }\n      if (e.target.closest('.vs-mobile-sheet-close')) { hide(); return; }\n      if (e.target.closest('.vs-mobile-sheet-handle')) { snapTo(cur + 1 < snaps.length ? cur + 1 : 0); return; }\n      const a = e.target.closest('a[href]');\n      if (a && root.dataset.closeOnNav !== 'false') hide();\n    }, true);\n    backdrop.addEventListener('click', () => hide());\n    if (handle) handle.addEventListener('keydown', e => {\n      if (e.key === 'ArrowUp') { e.preventDefault(); snapTo(cur + 1); }\n      if (e.key === 'ArrowDown') { e.preventDefault(); cur === 0 ? hide() : snapTo(cur - 1); }\n    });\n    root.addEventListener('keydown', e => {\n      if (e.key === 'Escape') { e.preventDefault(); hide(); return; }\n      if (e.key !== 'Tab') return;\n      const f = [...panel.querySelectorAll(FOCUSABLE)].filter(x => x.offsetParent !== null);\n      if (!f.length) return;\n      const i = f.indexOf(document.activeElement);\n      if (e.shiftKey && (i <= 0)) { e.preventDefault(); f[f.length - 1].focus(); }\n      else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }\n    });\n    document.addEventListener('click', e => {\n      const t = root.id && e.target.closest(`[aria-controls=\"${root.id}\"]`);\n      if (t && !t.disabled) { e.preventDefault(); open ? hide() : show(); }\n    });\n    triggers().forEach(t => { t.setAttribute('aria-expanded', 'false'); t.setAttribute('aria-haspopup', 'dialog'); });\n    new ResizeObserver(() => { if (!root.hidden) { measure(); render(); } }).observe(root);\n    new MutationObserver(() => { if (!root.hidden) { measure(); wake(api); } }).observe(root, { attributes: true, attributeFilter: ['data-snaps', 'data-scale', 'class'] });\n    if (document.fonts) document.fonts.ready.then(() => { if (!root.hidden) measure(); });\n    items.forEach(it => { it.el.style.opacity = '0'; });\n    root.__vsSheet = api;\n    if (root.hasAttribute('data-open')) show();\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-mobile-sheet').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsMobileSheet = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Navigation",
    "tags": [
      "bottom sheet",
      "drawer",
      "mobile menu",
      "drag",
      "snap points",
      "spring",
      "dialog",
      "navigation"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#2f8a5b",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/mobile-sheet/index.html",
    "thumbnail": "https://vividsites.app/api/component/mobile-sheet/thumb.jpg",
    "video": "https://vividsites.app/api/component/mobile-sheet/thumb.mp4",
    "page": "https://vividsites.app/c/mobile-sheet"
  }
}