{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-modal",
  "type": "registry:block",
  "title": "Spring Modal",
  "description": "A dialog that springs open out of the button that opened it: the button's shape morphs into the dialog's with round corners at every frame, the backdrop blur ramps with it, and it closes back into the button, interruptibly. Focus trap, Escape, click outside and drag-down on touch.",
  "docs": "Spring Modal was added to public/vividsites/spring-modal.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/spring-modal",
  "categories": [
    "ui",
    "loaders-cursors-feedback"
  ],
  "files": [
    {
      "path": "components/vividsites/spring-modal.html",
      "type": "registry:file",
      "target": "public/vividsites/spring-modal.html",
      "content": "<!--\n  vs-spring-modal: a dialog that grows out of the button that opened it. The button's own shape\n  (position, size, corner radius, fill) morphs into the dialog's on one spring: the surface is cut\n  with clip-path, so corners stay perfectly round at every frame (no scale distortion), and the\n  content fades in only once the shape has landed. The backdrop's dim and blur ramp with the same\n  progress. Closing runs it backwards into the button, from wherever it is: click the button again\n  mid-close and it reopens from there, with its velocity.\n  On touch, drag the dialog down to dismiss (velocity decides; up rubber-bands). Below 600px it is a\n  bottom sheet.\n\n  Markup: a trigger with aria-controls (or data-vs-spring-modal-open) naming a div.vs-spring-modal\n  with role=\"dialog\" aria-modal=\"true\" and hidden. Anything with data-vs-spring-modal-close closes it.\n  Accessibility: focus moves into the dialog ([autofocus] or the first field) and is trapped there;\n  Escape and a click outside close it; focus returns to the trigger. Page scroll is locked with the\n  scrollbar's width padded back, so nothing shifts.\n  JS: dialog.vsSpringModal.open() / .close() / .toggle(); events 'vs-spring-modal:open' / ':close'.\n  Put data-vs-spring-modal-theme=\"light\" on <html> (or any ancestor) for the light surface.\n  prefers-reduced-motion: no morph, a short fade.\n\n  Variables (on the trigger and the dialog; read on every open):\n    --vs-spring-modal-size        15px      type size; the dialog and trigger are measured in em\n    --vs-spring-modal-accent      #3ddc97   primary button, icons, focus ring\n    --vs-spring-modal-accent-ink  #04150d   text on the accent\n    --vs-spring-modal-surface / -raised / -ink / -mute / -line     dialog colours\n    --vs-spring-modal-radius      1.4em     the dialog's corner (the trigger keeps its own)\n    --vs-spring-modal-blur        12px      backdrop blur at full open\n    --vs-spring-modal-scrim       rgba(6,7,9,.5)\n    --vs-spring-modal-stiffness   220       spring stiffness\n    --vs-spring-modal-bounce      .3        0 = no overshoot … .5 = lively (closing never overshoots)\n-->\n<link rel=\"stylesheet\" href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600&display=swap\">\n<style>\n  .vs-spring-modal-trigger, .vs-spring-modal-root, .vs-spring-modal {\n    --vs-spring-modal-size: 15px;\n    --vs-spring-modal-accent: #3ddc97;\n    --vs-spring-modal-accent-ink: #04150d;\n    --vs-spring-modal-surface: #17181b;\n    --vs-spring-modal-raised: #1f2024;\n    --vs-spring-modal-trigger-bg: #1e2023;\n    --vs-spring-modal-ink: #eceef0;\n    --vs-spring-modal-mute: rgba(236,238,240,.55);\n    --vs-spring-modal-line: rgba(255,255,255,.085);\n    --vs-spring-modal-edge: rgba(255,255,255,.1);\n    --vs-spring-modal-radius: 1.4em;\n    --vs-spring-modal-blur: 12px;\n    --vs-spring-modal-scrim: rgba(6,7,9,.5);\n    --vs-spring-modal-stiffness: 220;\n    --vs-spring-modal-bounce: .3;\n    --vs-spring-modal-font: \"General Sans\", system-ui, -apple-system, \"Segoe UI\", sans-serif;\n    --vs-spring-modal-shadow: 0 1px 1px rgba(0,0,0,.25), 0 12px 24px -8px rgba(0,0,0,.45), 0 40px 80px -24px rgba(0,0,0,.6);\n  }\n  [data-vs-spring-modal-theme=\"light\"] :is(.vs-spring-modal-trigger, .vs-spring-modal-root, .vs-spring-modal) {\n    --vs-spring-modal-accent-ink: #04150d;\n    --vs-spring-modal-surface: #ffffff;\n    --vs-spring-modal-raised: #f4f5f6;\n    --vs-spring-modal-trigger-bg: #ffffff;\n    --vs-spring-modal-ink: #141518;\n    --vs-spring-modal-mute: rgba(20,21,24,.55);\n    --vs-spring-modal-line: rgba(10,12,16,.09);\n    --vs-spring-modal-edge: rgba(10,12,16,.1);\n    --vs-spring-modal-scrim: rgba(24,26,30,.22);\n    --vs-spring-modal-shadow: 0 1px 1px rgba(0,0,0,.04), 0 12px 24px -8px rgba(0,0,0,.12), 0 40px 80px -24px rgba(0,0,0,.25);\n  }\n\n  /* ── trigger ── */\n  .vs-spring-modal-trigger {\n    display: inline-flex; align-items: center; gap: .55em; height: 2.9em; box-sizing: border-box; white-space: nowrap; padding: 0 1.25em 0 1.05em; border: 0; border-radius: 999px;\n    font: 500 var(--vs-spring-modal-size)/1 var(--vs-spring-modal-font); letter-spacing: -.005em; color: var(--vs-spring-modal-ink);\n    background: var(--vs-spring-modal-trigger-bg); cursor: pointer; -webkit-tap-highlight-color: transparent;\n    box-shadow: 0 0 0 1px var(--vs-spring-modal-edge), inset 0 1px 0 rgba(255,255,255,.06), 0 1px 2px rgba(0,0,0,.2), 0 8px 20px -8px rgba(0,0,0,.4);\n    transition: transform .35s cubic-bezier(.34,1.56,.64,1), box-shadow .2s cubic-bezier(.2,0,0,1), filter .2s cubic-bezier(.2,0,0,1);\n    -webkit-font-smoothing: antialiased;\n  }\n  .vs-spring-modal-trigger svg { width: 1.15em; height: 1.15em; color: var(--vs-spring-modal-accent); flex: none; }\n  .vs-spring-modal-trigger:hover { filter: brightness(1.12); }\n  .vs-spring-modal-trigger:active { transform: scale(.965); transition-duration: .12s; }\n  .vs-spring-modal-trigger:focus-visible { outline: 2px solid var(--vs-spring-modal-accent); outline-offset: 3px; }\n  .vs-spring-modal-trigger:disabled { opacity: .45; cursor: not-allowed; }\n  .vs-spring-modal-trigger.is-morphing { opacity: 0; }\n\n  /* ── layers (created by the script) ── */\n  .vs-spring-modal-root { position: fixed; inset: 0; z-index: 1000; pointer-events: none; display: none; font-size: var(--vs-spring-modal-size); }\n  .vs-spring-modal-root.is-active { display: block; }\n  .vs-spring-modal-backdrop {\n    position: fixed; inset: 0; opacity: 0; background: var(--vs-spring-modal-scrim); touch-action: none;\n    -webkit-backdrop-filter: blur(var(--vs-spring-modal-blur)) saturate(1.15); backdrop-filter: blur(var(--vs-spring-modal-blur)) saturate(1.15);\n  }\n  .vs-spring-modal-root.is-open .vs-spring-modal-backdrop, .vs-spring-modal-root.is-open .vs-spring-modal { pointer-events: auto; }\n  .vs-spring-modal-shadow { position: fixed; left: 0; top: 0; transform-origin: 0 0; border-radius: var(--vs-spring-modal-radius); box-shadow: var(--vs-spring-modal-shadow); opacity: 0; }\n  .vs-spring-modal-edge, .vs-spring-modal-shape { position: fixed; inset: 0; clip-path: inset(50%); }\n  .vs-spring-modal-edge { background: var(--vs-spring-modal-edge); }\n  .vs-spring-modal-shape { background: var(--vs-spring-modal-surface); }\n  .vs-spring-modal-ghost { position: fixed !important; left: 0; top: 0; margin: 0 !important; background: none !important; box-shadow: none !important; filter: none !important; pointer-events: none; transition: none !important; }\n  .vs-spring-modal-probe { position: absolute; width: 0; height: 0; overflow: hidden; color: var(--vs-spring-modal-surface); }\n\n  /* ── dialog ── */\n  .vs-spring-modal {\n    position: fixed; inset: 0; margin: auto; box-sizing: border-box;\n    width: min(30em, calc(100vw - 32px)); height: fit-content; max-height: calc(100dvh - 32px);\n    font: 400 var(--vs-spring-modal-size)/1.45 var(--vs-spring-modal-font); color: var(--vs-spring-modal-ink); letter-spacing: -.003em;\n    border-radius: var(--vs-spring-modal-radius); outline: none; overscroll-behavior: contain;\n    -webkit-font-smoothing: antialiased; pointer-events: none;\n  }\n  .vs-spring-modal[hidden] { display: none; }\n  .vs-spring-modal-content { padding: 1.55em; opacity: 0; will-change: transform, opacity; max-height: calc(100dvh - 32px); overflow: auto; box-sizing: border-box; }\n  .vs-spring-modal-grip { display: none; width: 2.4em; height: .3em; margin: -.6em auto 1em; border-radius: 1em; background: var(--vs-spring-modal-line); }\n  .vs-spring-modal h2 { margin: 0; font-size: 1.33em; line-height: 1.2; font-weight: 600; letter-spacing: -.02em; }\n  .vs-spring-modal-sub { margin: .35em 0 0; color: var(--vs-spring-modal-mute); font-size: .93em; }\n  .vs-spring-modal-x {\n    position: absolute; top: 1.1em; right: 1.1em; width: 2em; height: 2em; border: 0; border-radius: 50%; padding: 0; cursor: pointer;\n    display: grid; place-items: center; color: var(--vs-spring-modal-mute); background: transparent; transition: background .15s cubic-bezier(.2,0,0,1), color .15s;\n  }\n  .vs-spring-modal-x svg { width: .85em; height: .85em; }\n  .vs-spring-modal-x:hover { background: var(--vs-spring-modal-line); color: var(--vs-spring-modal-ink); }\n  .vs-spring-modal-label { display: block; margin: 1.45em 0 .5em; font-size: .87em; font-weight: 500; }\n  .vs-spring-modal-field {\n    display: flex; flex-wrap: wrap; align-items: center; gap: .4em; padding: .42em; border-radius: .85em; cursor: text;\n    background: var(--vs-spring-modal-raised); box-shadow: inset 0 0 0 1px var(--vs-spring-modal-line);\n    transition: box-shadow .18s cubic-bezier(.2,0,0,1);\n  }\n  .vs-spring-modal-field:focus-within { box-shadow: inset 0 0 0 1px var(--vs-spring-modal-accent), 0 0 0 3px color-mix(in srgb, var(--vs-spring-modal-accent) 22%, transparent); }\n  .vs-spring-modal-chip {\n    display: inline-flex; align-items: center; gap: .42em; height: 2em; padding: 0 .3em 0 .25em; border-radius: 999px; font-size: .87em; font-weight: 500;\n    background: var(--vs-spring-modal-surface); box-shadow: 0 0 0 1px var(--vs-spring-modal-line), 0 1px 2px rgba(0,0,0,.12);\n  }\n  .vs-spring-modal-av { width: 1.55em; height: 1.55em; border-radius: 50%; display: grid; place-items: center; font-size: .68em; font-weight: 600; letter-spacing: .02em; color: #fff; background: var(--c, #3a7bd5); box-shadow: inset 0 0 0 1px rgba(255,255,255,.14); }\n  .vs-spring-modal-chip .vs-spring-modal-av { width: 2.25em; height: 2.25em; }\n  .vs-spring-modal-rm { width: 1.45em; height: 1.45em; border: 0; padding: 0; border-radius: 50%; display: grid; place-items: center; cursor: pointer; color: var(--vs-spring-modal-mute); background: none; }\n  .vs-spring-modal-rm svg { width: .62em; height: .62em; }\n  .vs-spring-modal-rm:hover { background: var(--vs-spring-modal-line); color: var(--vs-spring-modal-ink); }\n  .vs-spring-modal-field input {\n    flex: 1; min-width: 7em; height: 2em; border: 0; padding: 0 .45em; background: none; outline: none;\n    font: inherit; font-size: .93em; color: var(--vs-spring-modal-ink);\n  }\n  .vs-spring-modal-field input::placeholder { color: var(--vs-spring-modal-mute); }\n  .vs-spring-modal-role { display: flex; align-items: center; gap: 1em; margin-top: 1.1em; padding: .85em .95em; border-radius: .85em; box-shadow: inset 0 0 0 1px var(--vs-spring-modal-line); }\n  .vs-spring-modal-role > span { flex: 1; min-width: 0; font-size: .87em; color: var(--vs-spring-modal-mute); line-height: 1.35; }\n  .vs-spring-modal-role b { display: block; color: var(--vs-spring-modal-ink); font-weight: 500; }\n  .vs-spring-modal-select {\n    display: inline-flex; align-items: center; gap: .45em; height: 2.2em; padding: 0 .7em 0 .85em; border: 0; border-radius: .65em; cursor: pointer; white-space: nowrap;\n    font: 500 .87em/1 var(--vs-spring-modal-font); color: var(--vs-spring-modal-ink); background: var(--vs-spring-modal-raised); box-shadow: inset 0 0 0 1px var(--vs-spring-modal-line);\n    transition: background .15s cubic-bezier(.2,0,0,1);\n  }\n  .vs-spring-modal-select svg { width: .8em; height: .8em; color: var(--vs-spring-modal-mute); }\n  .vs-spring-modal-select:hover { background: var(--vs-spring-modal-line); }\n  .vs-spring-modal-foot { display: flex; align-items: center; gap: .6em; margin-top: 1.4em; padding-top: 1.2em; border-top: 1px solid var(--vs-spring-modal-line); }\n  .vs-spring-modal-btn {\n    display: inline-flex; align-items: center; justify-content: center; gap: .5em; height: 2.6em; padding: 0 1.05em; border: 0; border-radius: .75em; cursor: pointer;\n    font: 500 .93em/1 var(--vs-spring-modal-font); letter-spacing: -.005em; white-space: nowrap; -webkit-tap-highlight-color: transparent;\n    transition: transform .3s cubic-bezier(.34,1.56,.64,1), filter .15s cubic-bezier(.2,0,0,1), background .15s cubic-bezier(.2,0,0,1);\n  }\n  .vs-spring-modal-btn svg { width: 1.05em; height: 1.05em; }\n  .vs-spring-modal-btn:active { transform: scale(.96); transition-duration: .1s; }\n  .vs-spring-modal-btn.is-2 { color: var(--vs-spring-modal-ink); background: transparent; box-shadow: inset 0 0 0 1px var(--vs-spring-modal-line); }\n  .vs-spring-modal-btn.is-2:hover { background: var(--vs-spring-modal-line); }\n  .vs-spring-modal-btn.is-1 { margin-left: auto; color: var(--vs-spring-modal-accent-ink); background: var(--vs-spring-modal-accent); font-weight: 600;\n    box-shadow: inset 0 1px 0 rgba(255,255,255,.35), inset 0 -1px 0 rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.2), 0 6px 16px -6px color-mix(in srgb, var(--vs-spring-modal-accent) 60%, transparent); }\n  .vs-spring-modal-btn.is-1:hover { filter: brightness(1.06) saturate(1.05); }\n  .vs-spring-modal :is(button, input):focus-visible { outline: 2px solid var(--vs-spring-modal-accent); outline-offset: 2px; }\n  .vs-spring-modal-field input:focus-visible { outline: none; }\n  .vs-spring-modal-btn:disabled { opacity: .45; cursor: not-allowed; }\n  @media (max-width: 600px) {\n    .vs-spring-modal { inset: auto 8px 8px 8px; width: auto; --vs-spring-modal-radius: 1.75em; }\n    .vs-spring-modal-grip { display: block; }\n    .vs-spring-modal-x { display: none; }\n    .vs-spring-modal-foot { flex-wrap: wrap; }\n    .vs-spring-modal-btn { flex: 1; }\n  }\n</style>\n<button class=\"vs-spring-modal-trigger\" type=\"button\" aria-haspopup=\"dialog\" aria-controls=\"vs-invite\">\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" aria-hidden=\"true\"><circle cx=\"8\" cy=\"7\" r=\"3.2\"/><path d=\"M2.5 16.5c.7-2.9 3-4.5 5.5-4.5s4.8 1.6 5.5 4.5M15.5 6.5v5M13 9h5\"/></svg>\n  Invite to project\n</button>\n\n<div class=\"vs-spring-modal\" id=\"vs-invite\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"vs-invite-t\" aria-describedby=\"vs-invite-d\" hidden>\n  <div class=\"vs-spring-modal-grip\" aria-hidden=\"true\"></div>\n  <h2 id=\"vs-invite-t\">Invite to project</h2>\n  <p class=\"vs-spring-modal-sub\" id=\"vs-invite-d\">They'll get access to Harbor Redesign and its 14 files.</p>\n  <button class=\"vs-spring-modal-x\" type=\"button\" data-vs-spring-modal-close aria-label=\"Close\"><svg viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M2 2l8 8M10 2l-8 8\"/></svg></button>\n\n  <label class=\"vs-spring-modal-label\" for=\"vs-invite-email\">Email addresses</label>\n  <div class=\"vs-spring-modal-field\">\n    <span class=\"vs-spring-modal-chip\"><span class=\"vs-spring-modal-av\" style=\"--c:#2f8f83\">MO</span>mira@okafor.studio<button class=\"vs-spring-modal-rm\" type=\"button\" aria-label=\"Remove mira@okafor.studio\"><svg viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M2 2l6 6M8 2l-6 6\"/></svg></button></span>\n    <span class=\"vs-spring-modal-chip\"><span class=\"vs-spring-modal-av\" style=\"--c:#c2665a\">TL</span>theo@lind.co<button class=\"vs-spring-modal-rm\" type=\"button\" aria-label=\"Remove theo@lind.co\"><svg viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M2 2l6 6M8 2l-6 6\"/></svg></button></span>\n    <input id=\"vs-invite-email\" type=\"email\" placeholder=\"Add another…\" autocomplete=\"off\" autofocus>\n  </div>\n\n  <div class=\"vs-spring-modal-role\">\n    <span><b>Permission</b>Can edit files and leave comments</span>\n    <button class=\"vs-spring-modal-select\" type=\"button\" aria-label=\"Permission: Can edit\">Can edit<svg viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 4.5l3 3 3-3\"/></svg></button>\n  </div>\n\n  <div class=\"vs-spring-modal-foot\">\n    <button class=\"vs-spring-modal-btn is-2\" type=\"button\" data-vs-spring-modal-copy><svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M8.5 11.5a3.2 3.2 0 0 0 4.6.1l2.3-2.3a3.2 3.2 0 0 0-4.6-4.6l-.8.8M11.5 8.5a3.2 3.2 0 0 0-4.6-.1l-2.3 2.3a3.2 3.2 0 0 0 4.6 4.6l.8-.8\"/></svg><span>Copy link</span></button>\n    <button class=\"vs-spring-modal-btn is-1\" type=\"button\" data-vs-spring-modal-close>Send invite</button>\n  </div>\n</div>\n\n<script>\n(() => {\n  if (window.VsSpringModal) return;\n  const mq = matchMedia('(prefers-reduced-motion: reduce)');\n  const FOC = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n  const lerp = (a, b, t) => a + (b - a) * t, clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));\n  const rgba = (s) => { const m = String(s).match(/[\\d.]+/g) || [0, 0, 0]; return [+m[0], +m[1], +m[2], m[3] == null ? 1 : +m[3]]; };\n  const stack = [];\n\n  // scroll lock that pads the scrollbar's width back, so nothing under the dialog moves\n  let locks = 0, saved = null;\n  const lock = () => {\n    if (locks++) return; const de = document.documentElement, sbw = innerWidth - de.clientWidth;\n    saved = [de.style.overflow, de.style.paddingRight];\n    de.style.setProperty('--vs-spring-modal-sbw', sbw + 'px');\n    if (sbw > 0) de.style.paddingRight = (parseFloat(getComputedStyle(de).paddingRight) || 0) + sbw + 'px';\n    de.style.overflow = 'hidden';\n  };\n  const unlock = () => { if (--locks > 0) return; locks = 0; const de = document.documentElement; if (saved) { de.style.overflow = saved[0]; de.style.paddingRight = saved[1]; } };\n\n  class Spring {\n    constructor(v, k, c) { this.v = this.t = v; this.vel = 0; this.k = k; this.c = c; }\n    step(h) { this.vel += (-this.k * (this.v - this.t) - this.c * this.vel) * h; this.v += this.vel * h; }\n    rest(e) { return Math.abs(this.v - this.t) < e && Math.abs(this.vel) < e * 8; }\n    snap(v) { this.v = this.t = v; this.vel = 0; }\n  }\n\n  class Modal {\n    constructor(d) {\n      this.d = d; d.vsSpringModal = this;\n      const id = d.id;\n      this.triggers = id ? [...document.querySelectorAll(`[aria-controls=\"${id}\"],[data-vs-spring-modal-open=\"${id}\"]`)] : [];\n      // wrap the dialog's children so they can fade and follow a drag without moving the dialog's box\n      const c = document.createElement('div'); c.className = 'vs-spring-modal-content';\n      while (d.firstChild) c.appendChild(d.firstChild); d.appendChild(c); this.content = c;\n      d.tabIndex = -1;\n      const root = document.createElement('div'); root.className = 'vs-spring-modal-root';\n      root.innerHTML = '<div class=\"vs-spring-modal-backdrop\"></div><div class=\"vs-spring-modal-shadow\"></div><div class=\"vs-spring-modal-edge\"></div><div class=\"vs-spring-modal-shape\"></div><span class=\"vs-spring-modal-probe\"></span>';\n      document.body.appendChild(root); root.appendChild(d);\n      this.root = root; [this.bd, this.sh, this.edge, this.shape, this.probe] = root.children;\n      this.p = new Spring(0, 290, 26); this.c = new Spring(0, 420, 40); this.dy = new Spring(0, 420, 40);\n      this.open_ = false; this.active = false; this.raf = 0; this.drag = null;\n      this.triggers.forEach((t) => t.addEventListener('click', () => (this.open_ && this.trigger === t ? this.close() : this.open(t))));\n      d.addEventListener('click', (e) => { if (e.target.closest('[data-vs-spring-modal-close]')) this.close(); });\n      let downOut = false;\n      this.bd.addEventListener('pointerdown', () => (downOut = true));\n      this.bd.addEventListener('click', () => { if (downOut) this.close(); downOut = false; });\n      this.bindDrag();\n      addEventListener('resize', () => { if (this.active) { this.measure(); this.kick(); } });\n    }\n    cfg() {\n      const cs = getComputedStyle(this.d), n = (k, fb) => { const v = parseFloat(cs.getPropertyValue(k)); return isNaN(v) ? fb : v; };\n      return { k: n('--vs-spring-modal-stiffness', 220), b: clamp(n('--vs-spring-modal-bounce', .3), 0, .9) };\n    }\n    measure() {\n      const t = this.trigger, d = this.d;\n      if (t && t.isConnected && t.offsetParent !== null) {\n        const r = t.getBoundingClientRect(), cs = getComputedStyle(t), w = parseFloat(cs.width) || t.offsetWidth, h = parseFloat(cs.height) || t.offsetHeight;\n        this.T = { x: r.left + r.width / 2 - w / 2, y: r.top + r.height / 2 - h / 2, w, h, r: parseFloat(cs.borderTopLeftRadius) || 0, bg: rgba(cs.backgroundColor) };\n        this.T.r = Math.min(this.T.r, w / 2, h / 2);\n        if (this.T.bg[3] === 0) this.T.bg = null;\n      } else this.T = null;\n      const r = d.getBoundingClientRect();\n      this.D = { x: r.left, y: r.top, w: r.width, h: r.height, r: parseFloat(getComputedStyle(d).borderTopLeftRadius) || 0, bg: rgba(getComputedStyle(this.probe).color) };\n      if (!this.T) this.T = { x: this.D.x + this.D.w * .1, y: this.D.y + this.D.h * .1, w: this.D.w * .8, h: this.D.h * .8, r: this.D.r, bg: null, none: true };\n      if (!this.T.bg) this.T.bg = this.D.bg;\n      if (this.ghost) this.ghost.remove(); this.ghost = null;\n      if (t && !this.T.none) {\n        const g = t.cloneNode(true); g.removeAttribute('id'); g.removeAttribute('aria-controls'); g.setAttribute('aria-hidden', 'true'); g.tabIndex = -1;\n        g.classList.remove('is-morphing'); g.classList.add('vs-spring-modal-ghost'); g.style.width = this.T.w + 'px'; g.style.height = this.T.h + 'px';\n        this.root.insertBefore(g, this.probe); this.ghost = g;\n      }\n      this.sh.style.width = this.D.w + 'px'; this.sh.style.height = this.D.h + 'px';\n      const c = this.content; c.style.touchAction = c.scrollHeight > c.clientHeight + 1 ? 'pan-y' : 'none';\n    }\n    open(trigger) {\n      if (trigger) this.trigger = trigger; else if (!this.trigger) this.trigger = this.triggers[0];\n      const was = this.active;\n      if (!this.active) { this.active = true; this.d.hidden = false; this.root.classList.add('is-active'); lock(); this.p.snap(0); this.c.snap(0); this.dy.snap(0); }\n      this.d.inert = false;\n      if (!this.open_) { this.returnTo = was ? this.returnTo : document.activeElement; stack.push(this); }\n      this.open_ = true; this.root.classList.add('is-open');\n      this.measure();\n      const { k, b } = this.cfg(); this.p.k = k; this.p.c = 2 * Math.sqrt(k) * (1 - b); this.p.t = 1;\n      if (this.trigger) this.trigger.setAttribute('aria-expanded', 'true');\n      const f = this.d.querySelector('[autofocus]') || this.d.querySelector(FOC) || this.d;\n      f.focus({ preventScroll: true });\n      if (mq.matches) { this.p.snap(0); this.p.t = 1; this.p.k = 900; this.p.c = 60; }\n      this.d.dispatchEvent(new CustomEvent('vs-spring-modal:open', { bubbles: true }));\n      this.kick();\n    }\n    close() {\n      if (!this.open_) return;\n      this.open_ = false; this.root.classList.remove('is-open'); this.d.inert = true;\n      const i = stack.indexOf(this); if (i > -1) stack.splice(i, 1);\n      this.measure();\n      const { k } = this.cfg(); this.p.k = k * 1.5; this.p.c = 2 * Math.sqrt(this.p.k) * .92; this.p.t = 0;\n      this.c.t = 0; this.c.k = 3000; this.c.c = 110;\n      if (mq.matches) { this.p.k = 900; this.p.c = 60; }\n      if (this.trigger) this.trigger.setAttribute('aria-expanded', 'false');\n      const back = this.trigger && this.trigger.isConnected ? this.trigger : this.returnTo;\n      if (back && back.focus) back.focus({ preventScroll: true });\n      this.d.dispatchEvent(new CustomEvent('vs-spring-modal:close', { bubbles: true }));\n      this.kick();\n    }\n    toggle(t) { this.open_ ? this.close() : this.open(t); }\n    kick() { if (!this.raf) { this.last = performance.now(); this.raf = requestAnimationFrame((n) => this.tick(n)); } }\n    tick(now) {\n      const dt = Math.min(.05, (now - this.last) / 1000); this.last = now;\n      const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n      for (let i = 0; i < n; i++) { this.p.step(h); this.c.step(h); if (!this.drag) this.dy.step(h); }\n      // the content comes in once the shape has (nearly) landed\n      if (this.open_ && this.c.t === 0 && this.p.v > .9) { this.c.t = 1; this.c.k = 420; this.c.c = 40; }\n      this.paint();\n      const done = !this.open_ && this.p.v < .004 && Math.abs(this.p.vel) < .08;\n      if (done) { this.finish(); this.raf = 0; return; }\n      const idle = this.open_ && this.p.rest(.0005) && this.c.rest(.001) && this.dy.rest(.05) && !this.drag;\n      this.raf = idle ? 0 : requestAnimationFrame((n) => this.tick(n));\n    }\n    paint() {\n      const T = this.T, D = this.D, vw = innerWidth, vh = innerHeight, rm = mq.matches;\n      const q = rm ? 1 : this.p.v, a = clamp(this.p.v), dy = this.dy.v;\n      const x = lerp(T.x, D.x, q), y = lerp(T.y, D.y + dy, q), w = Math.max(1, lerp(T.w, D.w, q)), h = Math.max(1, lerp(T.h, D.h, q));\n      const r = Math.max(0, Math.min(lerp(T.r, D.r, clamp(q, 0, 1)), w / 2, h / 2));\n      const ins = (e) => `inset(${(y - e).toFixed(2)}px ${(vw - x - w - e).toFixed(2)}px ${(vh - y - h - e).toFixed(2)}px ${(x - e).toFixed(2)}px round ${(r + e).toFixed(2)}px)`;\n      this.shape.style.clipPath = ins(0); this.edge.style.clipPath = ins(1);\n      const m = clamp(q * 1.6), c0 = T.bg, c1 = D.bg;\n      this.shape.style.backgroundColor = `rgba(${lerp(c0[0], c1[0], m) | 0},${lerp(c0[1], c1[1], m) | 0},${lerp(c0[2], c1[2], m) | 0},${lerp(c0[3], c1[3], m).toFixed(3)})`;\n      const shapeA = rm ? a : 1;\n      this.shape.style.opacity = this.edge.style.opacity = shapeA;\n      this.sh.style.transform = `translate(${x.toFixed(2)}px,${y.toFixed(2)}px) scale(${(w / D.w).toFixed(4)},${(h / D.h).toFixed(4)})`;\n      this.sh.style.opacity = (a * a).toFixed(3);\n      const dragFade = dy > 0 ? 1 - clamp(dy / (D.h * 1.2)) : 1;\n      this.bd.style.opacity = (a * dragFade).toFixed(3);\n      if (this.ghost) {\n        this.ghost.style.transform = `translate(${(x + w / 2 - T.w / 2).toFixed(2)}px,${(y + h / 2 - T.h / 2).toFixed(2)}px)`;\n        this.ghost.style.opacity = rm ? 0 : clamp(1 - q * 3.2).toFixed(3);\n      }\n      // the content is cut by the same moving shape, so it can never spill outside it\n      const settled = Math.abs(q - 1) < .0005 && Math.abs(dy) < .05;\n      this.d.style.clipPath = settled || rm ? '' : `inset(${(y - D.y - dy).toFixed(2)}px ${(D.x + D.w - x - w).toFixed(2)}px ${(D.y + dy + D.h - y - h).toFixed(2)}px ${(x - D.x).toFixed(2)}px round ${r.toFixed(2)}px)`;\n      const cv = clamp(this.c.v);\n      this.content.style.opacity = cv.toFixed(3);\n      this.content.style.transform = `translate3d(0,${(dy + (1 - cv) * 7).toFixed(2)}px,0)`;\n      if (this.trigger) this.trigger.classList.toggle('is-morphing', !rm);\n    }\n    finish() {\n      this.active = false; this.d.hidden = true; this.root.classList.remove('is-active');\n      if (this.trigger) this.trigger.classList.remove('is-morphing');\n      if (this.ghost) { this.ghost.remove(); this.ghost = null; }\n      this.p.snap(0); this.c.snap(0); this.dy.snap(0); unlock();\n    }\n    // touch: drag the dialog down to dismiss\n    bindDrag() {\n      const d = this.d, rb = (v) => (1 - 1 / (v * .55 / 60 + 1)) * 60;\n      d.addEventListener('pointerdown', (e) => {\n        if (e.pointerType === 'mouse' || !this.open_ || e.target.closest('input,textarea,select,button,a,[contenteditable]')) return;\n        if (this.content.scrollTop > 0) return;\n        this.drag = { y0: e.clientY, x0: e.clientX, id: e.pointerId, on: false, s: [[e.timeStamp, 0]] };\n      });\n      d.addEventListener('pointermove', (e) => {\n        const g = this.drag; if (!g || g.id !== e.pointerId) return;\n        const dy = e.clientY - g.y0, dx = e.clientX - g.x0;\n        if (!g.on) {\n          if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) { this.drag = null; return; }\n          if (Math.abs(dy) < 6) return;\n          g.on = true; d.setPointerCapture(e.pointerId);\n        }\n        g.s.push([e.timeStamp, dy]); while (g.s.length > 2 && e.timeStamp - g.s[0][0] > 90) g.s.shift();\n        this.dy.snap(dy > 0 ? dy : -rb(-dy)); g.dy = dy; this.kick();\n      });\n      const end = (e) => {\n        const g = this.drag; if (!g || g.id !== e.pointerId) return; this.drag = null;\n        if (!g.on) return;\n        const a = g.s[0], b = g.s[g.s.length - 1], v = (b[1] - a[1]) / Math.max(1, b[0] - a[0]);\n        if (e.type === 'pointerup' && g.dy > 0 && (v > .45 || g.dy > this.D.h * .32)) { this.p.vel = -Math.min(4, 1 + v * 2); this.close(); }\n        else { this.dy.t = 0; this.dy.vel = g.dy > 0 ? v * 1000 : 0; }\n        this.kick();\n      };\n      d.addEventListener('pointerup', end); d.addEventListener('pointercancel', end);\n    }\n  }\n\n  // Escape closes the top dialog; Tab stays inside it\n  document.addEventListener('keydown', (e) => {\n    const m = stack[stack.length - 1]; if (!m) return;\n    if (e.key === 'Escape') { e.preventDefault(); m.close(); return; }\n    if (e.key !== 'Tab') return;\n    const f = [...m.d.querySelectorAll(FOC)].filter((el) => el.offsetParent !== null || el === document.activeElement);\n    if (!f.length) { e.preventDefault(); return; }\n    const first = f[0], last = f[f.length - 1];\n    if (e.shiftKey && (document.activeElement === first || !m.d.contains(document.activeElement))) { e.preventDefault(); last.focus(); }\n    else if (!e.shiftKey && (document.activeElement === last || !m.d.contains(document.activeElement))) { e.preventDefault(); first.focus(); }\n  });\n  document.addEventListener('focusin', (e) => { const m = stack[stack.length - 1]; if (m && !m.d.contains(e.target) && !m.root.contains(e.target) && !e.target.closest('[aria-controls=\"' + m.d.id + '\"]')) (m.d.querySelector(FOC) || m.d).focus({ preventScroll: true }); });\n\n  // demo copy-link feedback (optional)\n  document.addEventListener('click', (e) => {\n    const b = e.target.closest('[data-vs-spring-modal-copy]'); if (!b) return;\n    const s = b.querySelector('span'); if (!s) return;\n    const t = s.textContent; if (t === 'Copied') return;\n    try { navigator.clipboard && navigator.clipboard.writeText(location.href).catch(() => {}); } catch (_) {}\n    s.textContent = 'Copied'; setTimeout(() => (s.textContent = t), 1400);\n  });\n\n  const init = () => document.querySelectorAll('.vs-spring-modal').forEach((d) => d.vsSpringModal || new Modal(d));\n  window.VsSpringModal = { init };\n  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Loaders, cursors & feedback",
    "tags": [
      "modal",
      "dialog",
      "morph",
      "flip",
      "spring",
      "sheet",
      "focus trap",
      "backdrop blur"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#3ddc97",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/spring-modal/index.html",
    "thumbnail": "https://vividsites.app/api/component/spring-modal/thumb.jpg",
    "video": "https://vividsites.app/api/component/spring-modal/thumb.mp4",
    "page": "https://vividsites.app/c/spring-modal"
  }
}