{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "faq-accordion",
  "type": "registry:block",
  "title": "FAQ Accordion",
  "description": "An FAQ whose answers open on a critically damped height spring while the text rises in and the open row lifts into a card. The icon morphs (a plus that lies down into a minus, or a V that folds into a ^), with single or multi-open, full arrow-key navigation, and answers that find-in-page can still reach.",
  "docs": "FAQ Accordion was added to public/vividsites/faq-accordion.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/faq-accordion",
  "categories": [
    "ui",
    "cards-layout"
  ],
  "files": [
    {
      "path": "components/vividsites/faq-accordion.html",
      "type": "registry:file",
      "target": "public/vividsites/faq-accordion.html",
      "content": "<!--\n  vs-faq-accordion: an FAQ whose answers open on a spring, with a morphing icon and a lifting card.\n\n  - Each question is a real <button> with aria-expanded / aria-controls; the answer is a region.\n  - Height runs on a critically damped spring from wherever it currently is, so fast clicking never\n    jumps. The frame loop only writes (height, transform, opacity) and reads nothing; the answer\n    box has contain: layout paint. The text rises in while the height opens, and the height hands\n    back to \"auto\" when it settles so text can reflow.\n  - The open row lifts into a soft card; the hairlines around it step aside.\n  - Closed answers use hidden=\"until-found\": find-in-page still finds them, and a match opens it.\n  - Keyboard: Tab / Enter / Space, plus Arrow Up/Down between questions and Home/End.\n  - Options (attributes on .vs-faq-accordion, live):\n      data-multiple                    allow several answers open (remove it again to keep only the latest)\n      data-icon=\"plus\" | \"chevron\"     plus lies down into a minus in a spinning ring, or a V folds into a ^\n    Put data-open on an item to start it open. el.vsAccordion.open(i) / .close(i) from script.\n  - prefers-reduced-motion: answers open instantly, no transitions.\n\n  CSS variables (on .vs-faq-accordion):\n    --vs-faq-accordion-ink       text colour\n    --vs-faq-accordion-accent    open icon fill, number, bullets, focus ring\n    --vs-faq-accordion-line      hairline between questions\n    --vs-faq-accordion-card      background of the open card\n    --vs-faq-accordion-q-size    question font size\n    --vs-faq-accordion-pad       vertical padding of each question (density)\n    --vs-faq-accordion-inset     side padding inside each row (room for the open card)\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=Space+Grotesk:wght@400;500&display=swap\" rel=\"stylesheet\">\n<style>\n.vs-faq-accordion{\n  --vs-faq-accordion-ink: #1b2a20;\n  --vs-faq-accordion-accent: #c2572f;\n  --vs-faq-accordion-line: rgba(27,42,32,.14);\n  --vs-faq-accordion-card: #f1f4ef;\n  --vs-faq-accordion-q-size: 20px;\n  --vs-faq-accordion-pad: 20px;\n  --vs-faq-accordion-inset: 18px;\n  color: var(--vs-faq-accordion-ink); font-family: \"Space Grotesk\", system-ui, sans-serif;\n  margin: 0; padding: 0; list-style: none; position: relative;\n  -webkit-font-smoothing: antialiased;\n}\n.vs-faq-accordion-item{ position: relative; isolation: isolate; padding: 0 var(--vs-faq-accordion-inset); }  /* the card lives inside the box: nothing bleeds out */\n/* hairline under each row; it steps aside for the open card and the row above it */\n.vs-faq-accordion-item::after{\n  content: \"\"; position: absolute; left: var(--vs-faq-accordion-inset); right: var(--vs-faq-accordion-inset); bottom: 0; height: 1px; background: var(--vs-faq-accordion-line);\n  transition: opacity .4s cubic-bezier(.2,0,0,1);\n}\n.vs-faq-accordion-item:last-child::after{ opacity: 0; }\n.vs-faq-accordion-item.is-open::after, .vs-faq-accordion-item:has(+ .is-open)::after{ opacity: 0; }\n/* the open row lifts into a card */\n.vs-faq-accordion-item::before{\n  content: \"\"; position: absolute; inset: 0; z-index: -1; border-radius: 18px; pointer-events: none;\n  background: var(--vs-faq-accordion-card);\n  box-shadow: inset 0 0 0 1px rgba(255,255,255,.7), inset 0 1px 0 #fff, 0 1px 2px rgba(27,42,32,.06), 0 18px 40px -22px rgba(27,42,32,.35);\n  opacity: 0; transform: scale(.975); transition: opacity .45s cubic-bezier(.2,0,0,1), transform .7s cubic-bezier(.34,1.3,.64,1);\n}\n.vs-faq-accordion-item.is-open::before{ opacity: 1; transform: none; }\n.vs-faq-accordion-item h3{ margin: 0; font: inherit; }\n\n.vs-faq-accordion-q{\n  appearance: none; border: 0; margin: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; width: 100%;\n  display: grid; grid-template-columns: 30px 1fr 32px; gap: 14px; align-items: center; padding: var(--vs-faq-accordion-pad) 0;\n  font: 500 var(--vs-faq-accordion-q-size)/1.3 \"Space Grotesk\", system-ui, sans-serif; letter-spacing: -.02em;\n  -webkit-tap-highlight-color: transparent; outline: none; position: relative;\n  transition: padding .35s cubic-bezier(.16,1,.3,1);\n}\n.vs-faq-accordion-q::before{ /* hover wash + focus ring, same shape as the card */\n  content: \"\"; position: absolute; inset: 3px calc(var(--vs-faq-accordion-inset) * -1); border-radius: 14px; background: rgba(27,42,32,.045);\n  opacity: 0; transition: opacity .2s cubic-bezier(.2,0,0,1); pointer-events: none;\n}\n.vs-faq-accordion-item:not(.is-open) .vs-faq-accordion-q:hover::before{ opacity: 1; }\n.vs-faq-accordion-q:focus-visible::before{ opacity: 1; background: transparent; box-shadow: 0 0 0 2px var(--vs-faq-accordion-accent); }\n.vs-faq-accordion-n{ font-size: 12px; font-weight: 500; letter-spacing: .04em; font-variant-numeric: tabular-nums; opacity: .4;\n  transition: opacity .3s cubic-bezier(.2,0,0,1), color .3s cubic-bezier(.2,0,0,1); }\n.vs-faq-accordion-text{ transition: opacity .3s cubic-bezier(.2,0,0,1), transform .5s cubic-bezier(.16,1,.3,1); }\n.vs-faq-accordion.has-open .vs-faq-accordion-item:not(.is-open) .vs-faq-accordion-text{ opacity: .6; }\n.vs-faq-accordion.has-open .vs-faq-accordion-item:not(.is-open) .vs-faq-accordion-q:hover .vs-faq-accordion-text{ opacity: 1; }\n.vs-faq-accordion-item:not(.is-open) .vs-faq-accordion-q:hover .vs-faq-accordion-text{ transform: translate3d(3px,0,0); }\n.vs-faq-accordion-item.is-open .vs-faq-accordion-n{ opacity: 1; color: var(--vs-faq-accordion-accent); }\n\n/* the icon: two bars in a ring.\n   plus    → the vertical bar lies down into a minus while the ring spins half a turn and fills.\n   chevron → the two bars swap angles, so the V folds over into a ^.                                  */\n.vs-faq-accordion-icon{ position: relative; width: 32px; height: 32px; border-radius: 50%; justify-self: end;\n  box-shadow: inset 0 0 0 1px rgba(27,42,32,.2);\n  transition: background-color .35s cubic-bezier(.2,0,0,1), box-shadow .35s cubic-bezier(.2,0,0,1), color .35s, transform .6s cubic-bezier(.34,1.56,.64,1); }\n.vs-faq-accordion-icon::before, .vs-faq-accordion-icon::after{\n  content: \"\"; position: absolute; left: 50%; top: 50%; width: 12px; height: 1.5px; margin: -.75px 0 0 -6px; border-radius: 2px;\n  background: currentColor; transition: transform .6s cubic-bezier(.34,1.56,.64,1), width .3s;\n}\n.vs-faq-accordion-icon::after{ transform: rotate(90deg); }\n.vs-faq-accordion-q:hover .vs-faq-accordion-icon{ box-shadow: inset 0 0 0 1px rgba(27,42,32,.5); }\n.vs-faq-accordion-q:active .vs-faq-accordion-icon{ transform: scale(.88); }\n.vs-faq-accordion-item.is-open .vs-faq-accordion-icon{ background: var(--vs-faq-accordion-accent); box-shadow: 0 6px 14px -6px var(--vs-faq-accordion-accent); transform: rotate(180deg); color: #fff; }\n.vs-faq-accordion-item.is-open .vs-faq-accordion-q:active .vs-faq-accordion-icon{ transform: rotate(180deg) scale(.88); }\n.vs-faq-accordion-item.is-open .vs-faq-accordion-icon::after{ transform: rotate(0deg); }\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-icon::before,\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-icon::after{ width: 8.5px; margin-left: -4.25px; }\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-icon::before{ transform: translate3d(-2.9px,.6px,0) rotate(45deg); }\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-icon::after{ transform: translate3d(2.9px,.6px,0) rotate(-45deg); }\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-item.is-open .vs-faq-accordion-icon{ transform: none; }\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-item.is-open .vs-faq-accordion-q:active .vs-faq-accordion-icon{ transform: scale(.88); }\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-item.is-open .vs-faq-accordion-icon::before{ transform: translate3d(-2.9px,-.6px,0) rotate(-45deg); }\n.vs-faq-accordion[data-icon=\"chevron\"] .vs-faq-accordion-item.is-open .vs-faq-accordion-icon::after{ transform: translate3d(2.9px,-.6px,0) rotate(45deg); }\n\n/* the answer: height on a spring (JS), text rises in on transform/opacity */\n.vs-faq-accordion-a{ overflow: hidden; height: 0; contain: layout paint; }\n.vs-faq-accordion-a[hidden]{ display: block; }       /* keep the box (height 0) so until-found can reveal it */\n.vs-faq-accordion-inner{ padding: 0 46px calc(var(--vs-faq-accordion-pad) + 4px) 44px; will-change: transform, opacity; }\n.vs-faq-accordion-inner p{ margin: 0 0 12px; max-width: 56ch; font-size: 15.5px; line-height: 1.6; letter-spacing: -.005em; opacity: .74; text-wrap: pretty; }\n.vs-faq-accordion-inner p:last-child{ margin-bottom: 0; }\n.vs-faq-accordion-inner a{ color: var(--vs-faq-accordion-accent); text-decoration: none; background: linear-gradient(currentColor, currentColor) 0 100% / 100% 1px no-repeat;\n  transition: background-size .35s cubic-bezier(.16,1,.3,1); }\n.vs-faq-accordion-inner a:hover{ background-size: 0 1px; background-position: 100% 100%; }\n.vs-faq-accordion-inner a:focus-visible{ outline: 2px solid var(--vs-faq-accordion-accent); outline-offset: 2px; border-radius: 2px; }\n.vs-faq-accordion-inner ul{ margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 15px; opacity: .74; }\n.vs-faq-accordion-inner li{ display: flex; gap: 10px; }\n.vs-faq-accordion-inner li::before{ content: \"\"; flex: none; width: 5px; height: 5px; border-radius: 50%; margin-top: .6em; background: var(--vs-faq-accordion-accent); }\n\n@media (max-width: 640px){\n  .vs-faq-accordion{ --vs-faq-accordion-q-size: 16.5px; --vs-faq-accordion-pad: 17px; --vs-faq-accordion-inset: 12px; }\n  .vs-faq-accordion-item::before{ border-radius: 14px; }\n  .vs-faq-accordion-q{ grid-template-columns: 24px 1fr 30px; gap: 10px; }\n  .vs-faq-accordion-icon{ width: 30px; height: 30px; }\n  .vs-faq-accordion-inner{ padding: 0 6px calc(var(--vs-faq-accordion-pad) + 2px) 34px; }\n  .vs-faq-accordion-inner p{ font-size: 15px; }\n}\n@media (prefers-reduced-motion: reduce){\n  .vs-faq-accordion *, .vs-faq-accordion *::before, .vs-faq-accordion *::after{ transition: none !important; }\n}\n</style>\n\n<div class=\"vs-faq-accordion\" data-icon=\"plus\">\n  <div class=\"vs-faq-accordion-item\" data-open>\n    <h3><button class=\"vs-faq-accordion-q\" type=\"button\"><span class=\"vs-faq-accordion-n\">01</span><span class=\"vs-faq-accordion-text\">Do I need to book, or can I walk in?</span><span class=\"vs-faq-accordion-icon\" aria-hidden=\"true\"></span></button></h3>\n    <div class=\"vs-faq-accordion-a\"><div class=\"vs-faq-accordion-inner\"><p>Walk-ins are welcome until the rooms fill, which on weekends is usually by eleven. Members can hold a two-hour slot up to a week ahead from the <a href=\"#\">booking page</a>; the front desk keeps a third of every hour free for people who just turn up.</p></div></div>\n  </div>\n  <div class=\"vs-faq-accordion-item\">\n    <h3><button class=\"vs-faq-accordion-q\" type=\"button\"><span class=\"vs-faq-accordion-n\">02</span><span class=\"vs-faq-accordion-text\">What should I bring on a first visit?</span><span class=\"vs-faq-accordion-icon\" aria-hidden=\"true\"></span></button></h3>\n    <div class=\"vs-faq-accordion-a\"><div class=\"vs-faq-accordion-inner\"><p>Very little. We lend towels, a robe and slides, and the lockers take a code you choose.</p><ul><li>A swimsuit, or rent one at the desk</li><li>A water bottle; there are taps on every floor</li><li>Twenty minutes before your slot for the induction</li></ul></div></div>\n  </div>\n  <div class=\"vs-faq-accordion-item\">\n    <h3><button class=\"vs-faq-accordion-q\" type=\"button\"><span class=\"vs-faq-accordion-n\">03</span><span class=\"vs-faq-accordion-text\">Can I pause my membership?</span><span class=\"vs-faq-accordion-icon\" aria-hidden=\"true\"></span></button></h3>\n    <div class=\"vs-faq-accordion-a\"><div class=\"vs-faq-accordion-inner\"><p>Yes, for up to three months a year, in whole weeks, at no charge. Pause from your account any time before the next billing date and we won't take a payment until you come back.</p></div></div>\n  </div>\n  <div class=\"vs-faq-accordion-item\">\n    <h3><button class=\"vs-faq-accordion-q\" type=\"button\"><span class=\"vs-faq-accordion-n\">04</span><span class=\"vs-faq-accordion-text\">Is there a quiet hour?</span><span class=\"vs-faq-accordion-icon\" aria-hidden=\"true\"></span></button></h3>\n    <div class=\"vs-faq-accordion-a\"><div class=\"vs-faq-accordion-inner\"><p>Every day from 07:00 to 08:00 and again from 21:00 to close. Phones stay in the lockers, voices stay low, and the cold plunge is kept at four degrees for the early crowd.</p></div></div>\n  </div>\n  <div class=\"vs-faq-accordion-item\">\n    <h3><button class=\"vs-faq-accordion-q\" type=\"button\"><span class=\"vs-faq-accordion-n\">05</span><span class=\"vs-faq-accordion-text\">Are the saunas mixed?</span><span class=\"vs-faq-accordion-icon\" aria-hidden=\"true\"></span></button></h3>\n    <div class=\"vs-faq-accordion-a\"><div class=\"vs-faq-accordion-inner\"><p>The main floor is mixed and swimwear is required. On Tuesday and Thursday evenings the upper sauna is women-only, and the steam room is always booked privately in pairs.</p></div></div>\n  </div>\n  <div class=\"vs-faq-accordion-item\">\n    <h3><button class=\"vs-faq-accordion-q\" type=\"button\"><span class=\"vs-faq-accordion-n\">06</span><span class=\"vs-faq-accordion-text\">How do guest passes work?</span><span class=\"vs-faq-accordion-icon\" aria-hidden=\"true\"></span></button></h3>\n    <div class=\"vs-faq-accordion-a\"><div class=\"vs-faq-accordion-inner\"><p>Members get four guest passes a year. Your guest books with your name at the desk, pays nothing, and gets the same induction you did. Unused passes roll over once.</p></div></div>\n  </div>\n</div>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  let uid = 0;\n  function init(root) {\n    if (root.dataset.vsReady) return; root.dataset.vsReady = '1';\n    const multiple = () => root.hasAttribute('data-multiple');\n    const items = [...root.querySelectorAll('.vs-faq-accordion-item')].map(el => {\n      const btn = el.querySelector('.vs-faq-accordion-q'), panel = el.querySelector('.vs-faq-accordion-a'), inner = panel.querySelector('.vs-faq-accordion-inner');\n      const id = panel.id || (panel.id = `vs-faq-accordion-a-${++uid}`);\n      btn.id = btn.id || `${id}-q`;\n      btn.setAttribute('aria-controls', id); panel.setAttribute('role', 'region'); panel.setAttribute('aria-labelledby', btn.id);\n      return { el, btn, panel, inner, s: { x: 0, v: 0, t: 0 }, H: 0, open: false, done: true, at: 0 };\n    });\n    const K = 260, C = 2 * Math.sqrt(K);   // critically damped: no bounce at the bottom edge\n    let raf = 0, last = 0, stamp = 0;\n    // the frame loop only WRITES (height, transform, opacity); every read happens once, at toggle time\n    const paint = it => {\n      const h = Math.max(0, it.s.x);\n      it.panel.style.height = h.toFixed(2) + 'px';\n      const p = it.H ? Math.min(1, h / it.H) : 0;\n      it.inner.style.opacity = Math.min(1, Math.max(0, (p - 0.12) / 0.6)).toFixed(3);\n      it.inner.style.transform = `translate3d(0,${((1 - p) * -12).toFixed(2)}px,0)`;\n    };\n    const settle = it => {\n      it.s.x = it.s.t; it.s.v = 0; paint(it);\n      if (it.open) { it.panel.style.height = 'auto'; it.inner.style.transform = ''; it.inner.style.opacity = ''; }\n      else it.panel.setAttribute('hidden', 'until-found');\n    };\n    const tick = now => {\n      const dt = Math.min(0.05, (now - last) / 1000); last = now;\n      const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n      let moving = false;\n      for (const it of items) {\n        if (it.done) continue;\n        for (let i = 0; i < n; i++) { it.s.v += (-K * (it.s.x - it.s.t) - C * it.s.v) * h; it.s.x += it.s.v * h; }\n        if (Math.abs(it.s.x - it.s.t) < 0.3 && Math.abs(it.s.v) < 2) { it.done = true; settle(it); } else { paint(it); moving = true; }\n      }\n      raf = moving ? requestAnimationFrame(tick) : 0;\n    };\n    const set = (it, open) => {\n      if (it.open === open) return;\n      it.s.x = it.panel.getBoundingClientRect().height;   // start from whatever is on screen (auto, or mid-flight)\n      it.open = open; it.el.classList.toggle('is-open', open); it.btn.setAttribute('aria-expanded', String(open));\n      if (open) { it.panel.removeAttribute('hidden'); it.at = ++stamp; }\n      it.H = it.inner.offsetHeight; it.s.t = open ? it.H : 0; it.done = false;\n      if (reduce.matches) { settle(it); it.done = true; return; }\n      if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); }\n    };\n    const sync = () => root.classList.toggle('has-open', items.some(o => o.open));\n    const toggle = (it, force) => {\n      const open = force ?? !it.open;\n      if (open && !multiple()) items.forEach(o => o !== it && set(o, false));\n      set(it, open); sync();\n    };\n    items.forEach((it, i) => {\n      it.btn.setAttribute('aria-expanded', 'false');\n      it.panel.setAttribute('hidden', 'until-found');\n      it.btn.addEventListener('click', () => toggle(it));\n      it.panel.addEventListener('beforematch', () => toggle(it, true));\n      it.btn.addEventListener('keydown', e => {\n        const j = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: items.length - 1 }[e.key];\n        if (j === undefined) return;\n        e.preventDefault(); items[(j + items.length) % items.length].btn.focus();\n      });\n      if (it.el.hasAttribute('data-open')) { it.open = true; it.at = ++stamp; it.el.classList.add('is-open'); it.btn.setAttribute('aria-expanded', 'true'); it.panel.removeAttribute('hidden'); it.panel.style.height = 'auto'; }\n    });\n    // switching back to single-open keeps only the most recently opened answer\n    new MutationObserver(() => {\n      if (multiple()) return;\n      const open = items.filter(o => o.open).sort((a, b) => b.at - a.at);\n      open.slice(1).forEach(o => set(o, false)); sync();\n    }).observe(root, { attributes: true, attributeFilter: ['data-multiple'] });\n    root.vsAccordion = { open: i => items[i] && toggle(items[i], true), close: i => items[i] && toggle(items[i], false) };\n    sync();\n  }\n  const boot = () => document.querySelectorAll('.vs-faq-accordion').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Cards & layout",
    "tags": [
      "faq",
      "accordion",
      "disclosure",
      "height animation",
      "icon morph",
      "accessible",
      "keyboard",
      "spring"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#c2572f",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/faq-accordion/index.html",
    "thumbnail": "https://vividsites.app/api/component/faq-accordion/thumb.jpg",
    "video": "https://vividsites.app/api/component/faq-accordion/thumb.mp4",
    "page": "https://vividsites.app/c/faq-accordion"
  }
}