{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "accordion-gallery",
  "type": "registry:block",
  "title": "Accordion Gallery",
  "description": "Photo panels that open one at a time on hover, tap or arrow keys. Each panel rides its own spring with the weights always summing to one, so the open panel overshoots and its neighbours give way while the row stays exactly full. It moves on transforms only and stacks vertically on narrow screens.",
  "docs": "Accordion Gallery was added to public/vividsites/accordion-gallery.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/accordion-gallery",
  "categories": [
    "ui",
    "cards-layout"
  ],
  "files": [
    {
      "path": "components/vividsites/accordion-gallery.html",
      "type": "registry:file",
      "target": "public/vividsites/accordion-gallery.html",
      "content": "<!--\n  vs-accordion-gallery: image panels that open one at a time on hover, tap or arrow keys.\n\n  - Horizontal on wide containers, stacked vertically below --vs-accordion-gallery-stack-at.\n  - Transform-only while it moves: each panel is two nested clip layers that counter-translate,\n    so the open panel grows without a single layout pass.\n  - Every panel rides its own spring (same stiffness, same damping), so the weights always sum to\n    one: the row stays exactly full even while the opening panel overshoots and its neighbours\n    give way. Interruptible mid-flight; the spring keeps its velocity.\n  - Hover intent: the delay grows with pointer speed and holds while the pointer is still\n    travelling, so a fast sweep across the row opens only the panel it lands on. Tap opens on touch. The row is one Tab stop (roving tabindex): arrow keys\n    move and open, Home/End jump.\n  - The open photo drifts with the pointer; pressing a panel sinks it slightly.\n  - The rAF loop runs only while something is settling. Optional data-autoplay=\"6000\" cycles\n    until the first interaction and pauses off-screen. prefers-reduced-motion: panels snap.\n\n  CSS variables (on .vs-accordion-gallery; change them live, it re-measures):\n    --vs-accordion-gallery-h          height of the row (horizontal mode)\n    --vs-accordion-gallery-closed     width of a closed panel, px (horizontal)\n    --vs-accordion-gallery-closed-y   height of a closed panel, px (stacked)\n    --vs-accordion-gallery-open-y     open panel height as a fraction of the width (stacked)\n    --vs-accordion-gallery-gap        gap between panels, px\n    --vs-accordion-gallery-radius     panel corner radius\n    --vs-accordion-gallery-bounce     0 = no overshoot, 1 = lively (spring damping)\n    --vs-accordion-gallery-drift      pointer parallax of the open photo, px (0 = off)\n    --vs-accordion-gallery-ink        text colour\n    --vs-accordion-gallery-accent     index chip, rule and focus ring colour\n    --vs-accordion-gallery-stack-at   container width (px) below which it stacks\n  Attributes: data-open=\"2\" (initial panel, 0-based), data-autoplay=\"6000\" (ms, optional).\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=Archivo:wdth,wght@100,400;125,400&family=IBM+Plex+Mono:wght@400&display=swap\" rel=\"stylesheet\">\n<style>\n.vs-accordion-gallery{\n  --vs-accordion-gallery-h: 540px;\n  --vs-accordion-gallery-closed: 72;\n  --vs-accordion-gallery-closed-y: 56;\n  --vs-accordion-gallery-open-y: 1.05;\n  --vs-accordion-gallery-gap: 8;\n  --vs-accordion-gallery-radius: 20px;\n  --vs-accordion-gallery-bounce: .5;\n  --vs-accordion-gallery-drift: 14;\n  --vs-accordion-gallery-ink: #f3f1ea;\n  --vs-accordion-gallery-accent: #bfe3ee;\n  --vs-accordion-gallery-stack-at: 640;\n  position: relative; width: 100%; height: var(--vs-accordion-gallery-h); overflow: hidden;\n  color: var(--vs-accordion-gallery-ink); font-family: \"Archivo\", system-ui, sans-serif;\n  list-style: none; margin: 0; padding: 0; contain: layout paint; container-type: inline-size;\n  -webkit-user-select: none; user-select: none; touch-action: manipulation;\n}\n.vs-accordion-gallery, .vs-accordion-gallery *, .vs-accordion-gallery *::before, .vs-accordion-gallery *::after{ box-sizing: border-box; }\n.vs-accordion-gallery.is-stacked{ height: auto; }\n.vs-accordion-gallery-panel{ /* layer A: sits at the slot's start edge and clips the near side */\n  position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;\n  border-radius: var(--vs-accordion-gallery-radius); will-change: transform; transform: translateZ(0);\n  pointer-events: none;   /* the slot box overlaps its neighbours; only the visible button takes the pointer */\n}\n.vs-accordion-gallery-win{ /* layer B: clips the far edge */\n  position: absolute; inset: 0; overflow: hidden; border-radius: var(--vs-accordion-gallery-radius);\n  will-change: transform; background: #1a1d1b;\n}\n.vs-accordion-gallery-body{ position: absolute; inset: 0; will-change: transform; }\n.vs-accordion-gallery-img{ position: absolute; inset: -4% -6%; width: 112%; height: 108%; object-fit: cover; display: block;\n  will-change: transform; transform-origin: 50% 50%; pointer-events: none; -webkit-user-drag: none; }\n.vs-accordion-gallery-shade{ position: absolute; inset: 0; pointer-events: none; will-change: opacity;\n  background: linear-gradient(to top, rgba(8,10,9,.88) 0%, rgba(8,10,9,.42) 30%, rgba(8,10,9,0) 56%),\n              linear-gradient(to bottom, rgba(8,10,9,.38), rgba(8,10,9,0) 24%); }\n.vs-accordion-gallery-dim{ position: absolute; inset: 0; background: #0b0d0c; pointer-events: none; will-change: opacity; }\n.vs-accordion-gallery-lift{ /* hover: a soft light from above, fades in on the panel under the pointer */\n  position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity .45s cubic-bezier(.16,1,.3,1);\n  background: radial-gradient(120% 60% at 50% 0%, rgba(255,255,255,.16), rgba(255,255,255,0) 60%); }\n\n.vs-accordion-gallery-hit{\n  position: absolute; inset: 0; z-index: 4; appearance: none; border: 0; padding: 0; margin: 0; background: transparent;\n  cursor: pointer; outline: none; -webkit-tap-highlight-color: transparent; border-radius: 0; pointer-events: auto;\n}\n.vs-accordion-gallery-panel.is-open .vs-accordion-gallery-hit{ cursor: default; }\n.vs-accordion-gallery-idx{\n  position: absolute; top: 16px; left: 16px; z-index: 3; pointer-events: none;\n  font: 400 11px/1 \"IBM Plex Mono\", ui-monospace, Menlo, monospace; letter-spacing: .06em; font-variant-numeric: tabular-nums;\n  width: 40px; padding: 7px 0 6px; text-align: center; border-radius: 999px;\n  background: rgba(10,12,11,.34); box-shadow: inset 0 0 0 1px rgba(255,255,255,.2), inset 0 1px 0 rgba(255,255,255,.12);\n  -webkit-backdrop-filter: blur(10px) saturate(1.4); backdrop-filter: blur(10px) saturate(1.4);\n  transition: background-color .5s cubic-bezier(.16,1,.3,1), color .5s, box-shadow .5s;\n}\n.vs-accordion-gallery-panel.is-open .vs-accordion-gallery-idx{ color: #0c0f0e; background: var(--vs-accordion-gallery-accent); box-shadow: 0 6px 18px -6px rgba(0,0,0,.5); }\n.vs-accordion-gallery-spine{ /* the closed label, set vertically */\n  position: absolute; left: 0; bottom: 22px; width: calc(var(--vs-accordion-gallery-closed) * 1px); z-index: 3; pointer-events: none;\n  display: flex; justify-content: center; will-change: opacity;\n}\n.vs-accordion-gallery-spine span{ writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap;\n  font: 400 13px/1 \"Archivo\", system-ui, sans-serif; font-stretch: 125%; letter-spacing: .08em; text-transform: uppercase;\n  transition: color .35s; }\n.vs-accordion-gallery-copy{\n  position: absolute; left: 0; bottom: 0; z-index: 3; padding: 0 30px 28px; pointer-events: none;\n  width: min(var(--vs-accordion-gallery-open, 100%), 580px);\n}\n.vs-accordion-gallery-copy > *{ will-change: opacity, transform; }\n.vs-accordion-gallery-copy h3{ margin: 0; font: 400 clamp(26px, 4.3cqi, 46px)/.9 \"Archivo\", system-ui, sans-serif;\n  font-stretch: 125%; letter-spacing: -.025em; text-transform: uppercase; text-wrap: balance; }\n.vs-accordion-gallery-copy h3 em{ font-style: normal; opacity: .5; }\n.vs-accordion-gallery-copy p{ margin: 14px 0 0; max-width: 42ch; font-size: 14.5px; line-height: 1.5; font-stretch: 100%; opacity: .8; }\n.vs-accordion-gallery-meta{ position: relative; display: flex; gap: 18px; margin-top: 16px; padding-top: 13px;\n  font: 400 10.5px/1 \"IBM Plex Mono\", ui-monospace, Menlo, monospace; letter-spacing: .1em; text-transform: uppercase; font-variant-numeric: tabular-nums; }\n.vs-accordion-gallery-meta span{ opacity: .72; }\n.vs-accordion-gallery-meta::before{ content: \"\"; position: absolute; left: 0; right: 0; top: 0; height: 1px; background: rgba(255,255,255,.2); }\n.vs-accordion-gallery-meta::after{ /* accent rule draws across when the panel settles open */\n  content: \"\"; position: absolute; left: 0; top: 0; width: 64px; height: 1px; background: var(--vs-accordion-gallery-accent);\n  transform-origin: 0 50%; transform: scaleX(var(--vs-accordion-gallery-rule, 0)); }\n.vs-accordion-gallery-ringL, .vs-accordion-gallery-ringR{ position: absolute; inset: 0; z-index: 5; pointer-events: none; opacity: 0; transition: opacity .2s; }\n.vs-accordion-gallery-ringR{ border-radius: inherit; box-shadow: inset 0 0 0 2px var(--vs-accordion-gallery-accent), inset 0 0 0 4px rgba(0,0,0,.35); }\n.vs-accordion-gallery-ringL{ border-radius: var(--vs-accordion-gallery-radius); box-shadow: inset 2px 0 0 0 var(--vs-accordion-gallery-accent), inset 4px 0 0 0 rgba(0,0,0,.35); }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-ringL{ box-shadow: inset 0 2px 0 0 var(--vs-accordion-gallery-accent), inset 0 4px 0 0 rgba(0,0,0,.35); }\n.vs-accordion-gallery-edge{ /* sub-pixel inner edge + top highlight, drawn on the far clip layer so it follows the live width */\n  position: absolute; inset: 0; z-index: 5; pointer-events: none; border-radius: inherit;\n  box-shadow: inset 0 0 0 1px rgba(255,255,255,.09), inset 0 1px 0 rgba(255,255,255,.14); }\n\n/* states */\n@media (hover: hover){\n  .vs-accordion-gallery-panel:not(.is-open):hover .vs-accordion-gallery-lift{ opacity: 1; }\n  .vs-accordion-gallery-panel:not(.is-open):hover .vs-accordion-gallery-spine span{ color: var(--vs-accordion-gallery-accent); }\n}\n.vs-accordion-gallery-panel:has(.vs-accordion-gallery-hit:focus-visible) :is(.vs-accordion-gallery-ringL, .vs-accordion-gallery-ringR){ opacity: 1; }\n.vs-accordion-gallery-panel.is-focus :is(.vs-accordion-gallery-ringL, .vs-accordion-gallery-ringR){ opacity: 1; }\n\n/* stacked (vertical) mode */\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-spine{ left: 68px; right: 0; bottom: auto; top: 0; width: auto;\n  height: calc(var(--vs-accordion-gallery-closed-y) * 1px); justify-content: flex-start; align-items: center; }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-spine span{ writing-mode: horizontal-tb; transform: none; font-size: 13px; }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-idx{ top: calc(var(--vs-accordion-gallery-closed-y) * .5px - 13px); left: 14px; }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-copy{ width: 100%; padding: 0 20px 20px; bottom: var(--vs-accordion-gallery-slack, 0px); }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-shade{ bottom: var(--vs-accordion-gallery-slack, 0px); }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-copy h3{ font-size: 30px; }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-copy p{ font-size: 13.5px; margin-top: 10px; }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-meta{ gap: 14px; margin-top: 12px; }\n@media (prefers-reduced-motion: reduce){\n  .vs-accordion-gallery-lift, .vs-accordion-gallery-idx, .vs-accordion-gallery-spine span{ transition: none; }\n}\n.vs-accordion-gallery-body::after{ content: \"\"; position: absolute; inset: 0; z-index: 5; pointer-events: none;\n  border-radius: var(--vs-accordion-gallery-radius); box-shadow: inset 1px 0 0 rgba(255,255,255,.09); }\n.vs-accordion-gallery.is-stacked .vs-accordion-gallery-body::after{ box-shadow: inset 0 1px 0 rgba(255,255,255,.14); }\n</style>\n\n<ul class=\"vs-accordion-gallery\" aria-label=\"Ring road, six stops\" data-open=\"2\">\n  <li class=\"vs-accordion-gallery-panel\">\n    <div class=\"vs-accordion-gallery-win\">\n      <div class=\"vs-accordion-gallery-body\">\n        <img class=\"vs-accordion-gallery-img\" src=\"assets/beach.webp\" alt=\"Aerial of a black sand beach, surf curling against basalt columns\" draggable=\"false\" loading=\"eager\" decoding=\"async\">\n        <div class=\"vs-accordion-gallery-dim\"></div>\n        <div class=\"vs-accordion-gallery-shade\"></div>\n        <div class=\"vs-accordion-gallery-lift\"></div>\n        <span class=\"vs-accordion-gallery-idx\" aria-hidden=\"true\">01</span>\n        <div class=\"vs-accordion-gallery-spine\" aria-hidden=\"true\"><span>Black sand beach</span></div>\n        <div class=\"vs-accordion-gallery-copy\">\n          <h3>Black sand <em>beach</em></h3>\n          <p>Basalt columns break the Atlantic into lace. Stand back from the fourth wave; it always comes further.</p>\n          <div class=\"vs-accordion-gallery-meta\"><span>63.40&deg; N</span><span>Km 180</span><span>Stop 01/06</span></div>\n        </div>\n        <div class=\"vs-accordion-gallery-ringL\"></div>\n        <button class=\"vs-accordion-gallery-hit\" type=\"button\" aria-expanded=\"false\" aria-label=\"Black sand beach, stop 1 of 6\"></button>\n      </div>\n      <div class=\"vs-accordion-gallery-edge\"></div>\n      <div class=\"vs-accordion-gallery-ringR\"></div>\n    </div>\n  </li>\n  <li class=\"vs-accordion-gallery-panel\">\n    <div class=\"vs-accordion-gallery-win\">\n      <div class=\"vs-accordion-gallery-body\">\n        <img class=\"vs-accordion-gallery-img\" src=\"assets/vault.webp\" alt=\"Inside an ice cave, blue walls opening to a warm sunset\" draggable=\"false\" loading=\"eager\" decoding=\"async\">\n        <div class=\"vs-accordion-gallery-dim\"></div>\n        <div class=\"vs-accordion-gallery-shade\"></div>\n        <div class=\"vs-accordion-gallery-lift\"></div>\n        <span class=\"vs-accordion-gallery-idx\" aria-hidden=\"true\">02</span>\n        <div class=\"vs-accordion-gallery-spine\" aria-hidden=\"true\"><span>Ice vault</span></div>\n        <div class=\"vs-accordion-gallery-copy\">\n          <h3>Ice <em>vault</em></h3>\n          <p>A glacier cave you can walk for twelve minutes a day, when the meltwater drops and the arch holds.</p>\n          <div class=\"vs-accordion-gallery-meta\"><span>64.05&deg; N</span><span>Km 377</span><span>Stop 02/06</span></div>\n        </div>\n        <div class=\"vs-accordion-gallery-ringL\"></div>\n        <button class=\"vs-accordion-gallery-hit\" type=\"button\" aria-expanded=\"false\" aria-label=\"Ice vault, stop 2 of 6\"></button>\n      </div>\n      <div class=\"vs-accordion-gallery-edge\"></div>\n      <div class=\"vs-accordion-gallery-ringR\"></div>\n    </div>\n  </li>\n  <li class=\"vs-accordion-gallery-panel\">\n    <div class=\"vs-accordion-gallery-win\">\n      <div class=\"vs-accordion-gallery-body\">\n        <img class=\"vs-accordion-gallery-img\" src=\"assets/lagoon.webp\" alt=\"A steaming milky-blue lagoon among dark lava at dusk\" draggable=\"false\" loading=\"eager\" decoding=\"async\">\n        <div class=\"vs-accordion-gallery-dim\"></div>\n        <div class=\"vs-accordion-gallery-shade\"></div>\n        <div class=\"vs-accordion-gallery-lift\"></div>\n        <span class=\"vs-accordion-gallery-idx\" aria-hidden=\"true\">03</span>\n        <div class=\"vs-accordion-gallery-spine\" aria-hidden=\"true\"><span>Mist lagoon</span></div>\n        <div class=\"vs-accordion-gallery-copy\">\n          <h3>Mist <em>lagoon</em></h3>\n          <p>Silica-white water at thirty-nine degrees, fed from a kilometre down. Best at blue hour, worst at noon.</p>\n          <div class=\"vs-accordion-gallery-meta\"><span>63.88&deg; N</span><span>Km 12</span><span>Stop 03/06</span></div>\n        </div>\n        <div class=\"vs-accordion-gallery-ringL\"></div>\n        <button class=\"vs-accordion-gallery-hit\" type=\"button\" aria-expanded=\"false\" aria-label=\"Mist lagoon, stop 3 of 6\"></button>\n      </div>\n      <div class=\"vs-accordion-gallery-edge\"></div>\n      <div class=\"vs-accordion-gallery-ringR\"></div>\n    </div>\n  </li>\n  <li class=\"vs-accordion-gallery-panel\">\n    <div class=\"vs-accordion-gallery-win\">\n      <div class=\"vs-accordion-gallery-body\">\n        <img class=\"vs-accordion-gallery-img\" src=\"assets/flow.webp\" alt=\"A lava field at dusk with glowing orange seams\" draggable=\"false\" loading=\"eager\" decoding=\"async\">\n        <div class=\"vs-accordion-gallery-dim\"></div>\n        <div class=\"vs-accordion-gallery-shade\"></div>\n        <div class=\"vs-accordion-gallery-lift\"></div>\n        <span class=\"vs-accordion-gallery-idx\" aria-hidden=\"true\">04</span>\n        <div class=\"vs-accordion-gallery-spine\" aria-hidden=\"true\"><span>Live flow</span></div>\n        <div class=\"vs-accordion-gallery-copy\">\n          <h3>Live <em>flow</em></h3>\n          <p>The crust walks at a metre an hour. Heat shimmers off it long after the glow has gone out.</p>\n          <div class=\"vs-accordion-gallery-meta\"><span>63.89&deg; N</span><span>Km 41</span><span>Stop 04/06</span></div>\n        </div>\n        <div class=\"vs-accordion-gallery-ringL\"></div>\n        <button class=\"vs-accordion-gallery-hit\" type=\"button\" aria-expanded=\"false\" aria-label=\"Live flow, stop 4 of 6\"></button>\n      </div>\n      <div class=\"vs-accordion-gallery-edge\"></div>\n      <div class=\"vs-accordion-gallery-ringR\"></div>\n    </div>\n  </li>\n  <li class=\"vs-accordion-gallery-panel\">\n    <div class=\"vs-accordion-gallery-win\">\n      <div class=\"vs-accordion-gallery-body\">\n        <img class=\"vs-accordion-gallery-img\" src=\"assets/moss.webp\" alt=\"A moss-covered lava field at sunrise with cotton grass\" draggable=\"false\" loading=\"lazy\" decoding=\"async\">\n        <div class=\"vs-accordion-gallery-dim\"></div>\n        <div class=\"vs-accordion-gallery-shade\"></div>\n        <div class=\"vs-accordion-gallery-lift\"></div>\n        <span class=\"vs-accordion-gallery-idx\" aria-hidden=\"true\">05</span>\n        <div class=\"vs-accordion-gallery-spine\" aria-hidden=\"true\"><span>Moss field</span></div>\n        <div class=\"vs-accordion-gallery-copy\">\n          <h3>Moss <em>field</em></h3>\n          <p>Two centuries of woolly moss over the Eldhraun flow. A footprint takes a decade to fill back in.</p>\n          <div class=\"vs-accordion-gallery-meta\"><span>63.72&deg; N</span><span>Km 256</span><span>Stop 05/06</span></div>\n        </div>\n        <div class=\"vs-accordion-gallery-ringL\"></div>\n        <button class=\"vs-accordion-gallery-hit\" type=\"button\" aria-expanded=\"false\" aria-label=\"Moss field, stop 5 of 6\"></button>\n      </div>\n      <div class=\"vs-accordion-gallery-edge\"></div>\n      <div class=\"vs-accordion-gallery-ringR\"></div>\n    </div>\n  </li>\n  <li class=\"vs-accordion-gallery-panel\">\n    <div class=\"vs-accordion-gallery-win\">\n      <div class=\"vs-accordion-gallery-body\">\n        <img class=\"vs-accordion-gallery-img\" src=\"assets/ridge.webp\" alt=\"A snow-streaked granite peak above a sea of cloud at dawn\" draggable=\"false\" loading=\"lazy\" decoding=\"async\">\n        <div class=\"vs-accordion-gallery-dim\"></div>\n        <div class=\"vs-accordion-gallery-shade\"></div>\n        <div class=\"vs-accordion-gallery-lift\"></div>\n        <span class=\"vs-accordion-gallery-idx\" aria-hidden=\"true\">06</span>\n        <div class=\"vs-accordion-gallery-spine\" aria-hidden=\"true\"><span>North ridge</span></div>\n        <div class=\"vs-accordion-gallery-copy\">\n          <h3>North <em>ridge</em></h3>\n          <p>Above the inversion the cloud turns rose for eleven minutes, then the wind comes up the col.</p>\n          <div class=\"vs-accordion-gallery-meta\"><span>64.01&deg; N</span><span>Km 402</span><span>Stop 06/06</span></div>\n        </div>\n        <div class=\"vs-accordion-gallery-ringL\"></div>\n        <button class=\"vs-accordion-gallery-hit\" type=\"button\" aria-expanded=\"false\" aria-label=\"North ridge, stop 6 of 6\"></button>\n      </div>\n      <div class=\"vs-accordion-gallery-edge\"></div>\n      <div class=\"vs-accordion-gallery-ringR\"></div>\n    </div>\n  </li>\n</ul>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n  const smooth = (a, b, v) => { const t = clamp((v - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };\n  const K = 170;                      // stiffness, shared by every panel (that is what keeps the weights summed to 1)\n  function init(root) {\n    if (root.dataset.vsReady) return; root.dataset.vsReady = '1';\n    const q = (el, s) => el.querySelector('.vs-accordion-gallery-' + s);\n    const panels = [...root.querySelectorAll('.vs-accordion-gallery-panel')].map((el) => {\n      const copy = q(el, 'copy');\n      return { el, win: q(el, 'win'), body: q(el, 'body'), img: q(el, 'img'), dim: q(el, 'dim'), shade: q(el, 'shade'),\n        spine: q(el, 'spine'), hit: q(el, 'hit'), bits: copy ? [...copy.children] : [], meta: q(el, 'meta'),\n        w: { x: 0, v: 0, t: 0 }, pr: { x: 0, v: 0, t: 0 } };\n    });\n    const n = panels.length; if (!n) return;\n    let active = clamp(+root.dataset.open || 0, 0, n - 1), raf = 0, last = 0;\n    let seen = null, L = 0, E = 0, Ew = 0, c = 72, g = 8, axis = 'x', C = 20, drift = 14, visible = true, interacted = false, hoverT = 0;\n    const px = { x: 0, v: 0, t: 0 };  // pointer drift spring, -1..1 across the row\n    const sp = { x: 0, y: 0, t: 0, speed: 0 };\n    panels[active].w.x = panels[active].w.t = 1;\n    const css = (k, d) => { const v = parseFloat(getComputedStyle(root).getPropertyValue(k)); return Number.isFinite(v) ? v : d; };\n\n    const readProps = () => {\n      const b = clamp(css('--vs-accordion-gallery-bounce', 0.5), 0, 1);\n      C = 2 * (1 - 0.62 * b) * Math.sqrt(K);          // damping ratio 1 (no overshoot) .. 0.38 (lively)\n      drift = css('--vs-accordion-gallery-drift', 14);\n    };\n    const measure = () => {\n      readProps();\n      axis = root.clientWidth < css('--vs-accordion-gallery-stack-at', 640) ? 'y' : 'x';\n      root.classList.toggle('is-stacked', axis === 'y');\n      c = axis === 'x' ? css('--vs-accordion-gallery-closed', 72) : css('--vs-accordion-gallery-closed-y', 56);\n      g = css('--vs-accordion-gallery-gap', 8);\n      if (axis === 'y') {\n        E = Math.round(root.clientWidth * css('--vs-accordion-gallery-open-y', 1.05));\n        L = E + (n - 1) * (c + g); if (root.style.height !== L + 'px') root.style.height = L + 'px';\n      } else { if (root.style.height) root.style.height = ''; L = root.clientWidth; E = L - (n - 1) * (c + g); }\n      Ew = Math.round(E + (E - c) * 0.35);           // headroom for the overshoot\n      root.style.setProperty('--vs-accordion-gallery-slack', (axis === 'y' ? Ew - E : 0) + 'px');\n      root.style.setProperty('--vs-accordion-gallery-open', E + 'px');   // the copy never outgrows the open panel\n      for (const p of panels) {\n        p.el.style.width = axis === 'x' ? Ew + 'px' : '100%'; p.el.style.height = axis === 'y' ? Ew + 'px' : '100%';\n      }\n      seen = root.getAttribute('style');\n      render();\n    };\n    const sizes = new Float64Array(64);\n    const render = () => {\n      // Sizes from the weights. A weight that dips below 0 (the closing panel's undershoot) is\n      // clipped, and the width it would have taken is shared out evenly by every other panel,\n      // so the opener overshoots, the rest of the row gives way, and the row stays exactly full.\n      const m = c * 0.5, target = E + (n - 1) * c; let sum = 0, big = 0;\n      for (let i = 0; i < n; i++) {\n        const s = Math.min(c + Math.max(0, panels[i].w.x) * (E - c), Ew); sizes[i] = s; sum += s;\n        if (s > sizes[big]) big = i;\n      }\n      const share = (sum - target) / (n - 1);\n      sum = 0;\n      for (let i = 0; i < n; i++) { if (i !== big) sizes[i] = Math.max(m, sizes[i] - share); sum += sizes[i]; }\n      sizes[big] = clamp(sizes[big] + target - sum, m, Ew);\n      // snap every clip edge to the device pixel grid so the stacked layers never disagree by a sliver\n      const dpr = devicePixelRatio || 1, snap = (v) => Math.round(v * dpr) / dpr;\n      const T = axis === 'x' ? ((v) => `translate3d(${snap(v)}px,0,0)`) : ((v) => `translate3d(0,${snap(v)}px,0)`);\n      let pos = 0;\n      for (let i = 0; i < n; i++) {\n        const p = panels[i], at = snap(pos), size = snap(pos + sizes[i]) - at, hide = Ew - size, w = clamp(p.w.x, 0, 1), pr = p.pr.x;\n        p.el.style.transform = T(at);\n        p.win.style.transform = T(-hide);\n        p.body.style.transform = T(hide);\n        const d = axis === 'x' ? px.x * drift * w : 0;\n        p.img.style.transform = `${axis === 'x' ? `translate3d(${(-hide / 2 + d).toFixed(2)}px,0,0)` : `translate3d(0,${(-hide / 2).toFixed(2)}px,0)`} scale(${(1.1 - 0.1 * w - 0.025 * pr).toFixed(4)})`;\n        p.dim.style.opacity = (0.46 * (1 - w) + 0.18 * pr).toFixed(3);\n        p.shade.style.opacity = (0.5 + 0.5 * w).toFixed(3);\n        p.spine.style.opacity = (1 - smooth(0.02, 0.28, w)).toFixed(3);\n        p.bits.forEach((b, k) => {                    // title, line, meta arrive one after another\n          const o = smooth(0.5 + k * 0.1, 0.92 + k * 0.04, w);\n          b.style.opacity = o.toFixed(3);\n          b.style.transform = `translate3d(0,${((1 - o) * (16 + k * 6)).toFixed(2)}px,0)`;\n        });\n        if (p.meta) p.meta.style.setProperty('--vs-accordion-gallery-rule', smooth(0.8, 1, w).toFixed(3));\n        pos += sizes[i] + g;           // accumulate the exact sizes, snap only the edges: no drift\n      }\n    };\n    const springs = () => { const a = [px]; for (const p of panels) a.push(p.w, p.pr); return a; };\n    const settled = (s) => Math.abs(s.x - s.t) < 1e-4 && Math.abs(s.v) < 1e-3;\n    const tick = (now) => {\n      const dt = Math.min(0.05, (now - last) / 1000); last = now;\n      const steps = Math.max(1, Math.ceil(dt * 240)), h = dt / steps;\n      for (let i = 0; i < steps; i++) {\n        for (const p of panels) {\n          const s = p.w; s.v += (-K * (s.x - s.t) - C * s.v) * h; s.x += s.v * h;\n          const r = p.pr; r.v += (-600 * (r.x - r.t) - 40 * r.v) * h; r.x += r.v * h;\n        }\n        px.v += (-60 * (px.x - px.t) - 15.5 * px.v) * h; px.x += px.v * h;\n      }\n      render();\n      const all = springs();\n      if (!visible || all.every(settled)) { all.forEach((s) => { s.x = s.t; s.v = 0; }); render(); raf = 0; }\n      else raf = requestAnimationFrame(tick);\n    };\n    const kick = () => {\n      if (reduce.matches) { springs().forEach((s) => { s.x = s.t; s.v = 0; }); render(); return; }\n      if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(tick); }\n    };\n    const mark = () => panels.forEach((p, j) => {\n      p.el.classList.toggle('is-open', j === active);\n      p.hit.setAttribute('aria-expanded', String(j === active));\n      p.hit.tabIndex = j === active ? 0 : -1;          // roving tab stop: Tab enters the row once, arrows move within it\n    });\n    const open = (i) => {\n      i = clamp(i, 0, n - 1);\n      if (i === active && panels[i].w.t === 1) return;\n      active = i; panels.forEach((p, j) => { p.w.t = j === i ? 1 : 0; }); mark(); kick();\n      root.dispatchEvent(new CustomEvent('vs-accordion-gallery:open', { detail: { index: i } }));\n    };\n    root.vsOpen = open;\n\n    // pointer: velocity-aware hover intent + drift\n    root.addEventListener('pointermove', (e) => {\n      if (e.pointerType !== 'mouse') return;\n      const now = performance.now(), dtm = Math.max(1, now - sp.t);\n      const dist = Math.hypot(e.clientX - sp.x, e.clientY - sp.y);\n      sp.speed = sp.speed * 0.6 + (dist / dtm) * 1000 * 0.4; sp.x = e.clientX; sp.y = e.clientY; sp.t = now;\n      const r = root.getBoundingClientRect();\n      px.t = clamp(((e.clientX - r.left) / r.width) * 2 - 1, -1, 1) * -1;\n      if (drift) kick();\n    });\n    root.addEventListener('pointerleave', () => { px.t = 0; clearTimeout(hoverT); kick(); });\n    panels.forEach((p, i) => {\n      p.el.addEventListener('pointerenter', (e) => {\n        if (e.pointerType !== 'mouse') return;\n        clearTimeout(hoverT);\n        // hover intent: the wait grows with pointer speed, and while the pointer is still travelling\n        // fast it keeps waiting, so sweeping across the row to reach a far panel opens only that one\n        const fire = () => {\n          if (performance.now() - sp.t < 45 && sp.speed > 900) { hoverT = setTimeout(fire, 45); return; }\n          interacted = true; open(i);\n        };\n        hoverT = setTimeout(fire, 50 + clamp((sp.speed - 600) * 0.08, 0, 170));\n      });\n      p.el.addEventListener('pointerleave', () => clearTimeout(hoverT));\n      p.el.addEventListener('pointerdown', () => { p.pr.t = 1; kick(); });\n      const up = () => { if (p.pr.t) { p.pr.t = 0; kick(); } };\n      ['pointerup', 'pointercancel', 'pointerleave'].forEach((t) => p.el.addEventListener(t, up));\n      p.hit.addEventListener('click', () => { interacted = true; clearTimeout(hoverT); open(i); });\n      p.hit.addEventListener('keydown', (e) => {\n        const next = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: n - 1 }[e.key];\n        if (next === undefined) return;\n        e.preventDefault(); interacted = true;\n        const j = clamp(next, 0, n - 1); panels[j].hit.focus({ preventScroll: true }); open(j);\n      });\n    });\n    mark();\n\n    new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) kick(); }).observe(root);\n    const every = +root.dataset.autoplay;\n    if (every > 0 && !reduce.matches) {\n      const loop = setInterval(() => {\n        if (interacted) return clearInterval(loop);\n        if (visible && !root.matches(':hover')) open((active + 1) % n);\n      }, every);\n    }\n    new ResizeObserver(measure).observe(root);\n    new MutationObserver(() => { if (root.getAttribute('style') !== seen) measure(); }).observe(root, { attributes: true, attributeFilter: ['style'] });\n    measure();\n  }\n  const boot = () => document.querySelectorAll('.vs-accordion-gallery').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Cards & layout",
    "tags": [
      "gallery",
      "accordion",
      "expanding panels",
      "hover",
      "image",
      "keyboard",
      "touch",
      "spring",
      "responsive"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@100,400;125,400&family=IBM+Plex+Mono:wght@400&display=swap"
    ],
    "source": "hraun",
    "free": true,
    "accent": "#bfe3ee",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/accordion-gallery/index.html",
    "thumbnail": "https://vividsites.app/api/component/accordion-gallery/thumb.jpg",
    "video": "https://vividsites.app/api/component/accordion-gallery/thumb.mp4",
    "page": "https://vividsites.app/c/accordion-gallery"
  }
}