{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lightbox",
  "type": "registry:block",
  "title": "Shared-Element Lightbox",
  "description": "A gallery whose thumbnails grow out of the grid into a full-screen viewer, crop and corners unfolding on a spring. Swipe between images, drag down to dismiss, double-tap, wheel or pinch to zoom and pan with momentum.",
  "docs": "Shared-Element Lightbox was added to public/vividsites/lightbox.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/lightbox",
  "categories": [
    "ui",
    "media"
  ],
  "files": [
    {
      "path": "components/vividsites/lightbox.html",
      "type": "registry:file",
      "target": "public/vividsites/lightbox.html",
      "content": "<!--\nvs-lightbox: a gallery grid whose images open into a full-screen viewer with a shared-element\n  transition: the thumbnail itself grows out of the grid, its crop and corner radius unfolding\n  into the whole picture on a spring, and it flies back to its slot when you close.\n  In the viewer: swipe or ←/→ between images (rubber-banded at the ends), drag down to dismiss\n  (the backdrop fades with the distance), double-click/double-tap to zoom at that point, wheel\n  or pinch to zoom, drag to pan with momentum and soft edges. + / − / 0 zoom, Esc closes.\n  Tap the picture to hide the chrome; click the backdrop to close. Focus is trapped while open\n  and returns to the thumbnail after.\n\n  Markup:  <div class=\"vs-lightbox\" data-max-zoom=\"3\">\n             <a class=\"vs-lightbox-item vs-lightbox-item--wide\" href=\"full.jpg\"\n                data-caption=\"Title\" data-meta=\"Where · lens\"><img src=\"small.jpg\" alt=\"...\"></a> ...\n           </div>\n  The grid is 4 columns (2 under 600px). Item modifiers: --wide (2 cols), --tall (2 rows),\n  --big (2×2). Use any layout you like: the viewer only needs the links.\n  Attributes: data-max-zoom on .vs-lightbox (default 3); href (or data-full) is the large image,\n    the <img> is shown instantly and swapped for the large one once it has decoded.\n  CSS variables: --vs-lightbox-radius, -gap, -row, -backdrop (r, g, b), -ink, -font\n  Events: \"open\" and \"close\" on .vs-lightbox, detail { index }.\n  JS: el.__vsLightbox.open(i) / close() / next() / prev() / zoom(scale); vsLightbox.mount(el).\n  Reduced motion: the viewer cuts instead of flying.\n-->\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-lightbox\" data-max-zoom=\"3\">\n  <a class=\"vs-lightbox-item vs-lightbox-item--big\" href=\"assets/lava.webp\" data-caption=\"Fissure field, second night\" data-meta=\"Reykjanes · 24 mm · 1/60 s\">\n    <img src=\"assets/lava-sm.webp\" alt=\"A cracked lava field glowing orange under a blue dusk\" loading=\"lazy\"></a>\n  <a class=\"vs-lightbox-item vs-lightbox-item--wide\" href=\"assets/peak.webp\" data-caption=\"Above the cloud line\" data-meta=\"Valais · 70 mm · 1/800 s\">\n    <img src=\"assets/peak-sm.webp\" alt=\"A lone granite peak rising out of a sea of cloud\" loading=\"lazy\"></a>\n  <a class=\"vs-lightbox-item\" href=\"assets/movement.webp\" data-caption=\"Calibre 7, caseback off\" data-meta=\"Studio · 100 mm macro\">\n    <img src=\"assets/movement-sm.webp\" alt=\"The gilded gears and rubies of a watch movement\" loading=\"lazy\"></a>\n  <a class=\"vs-lightbox-item\" href=\"assets/souk.webp\" data-caption=\"Spice lane before noon\" data-meta=\"Old medina · 35 mm · 1/125 s\">\n    <img src=\"assets/souk-sm.webp\" alt=\"A vaulted market lane lit by lanterns\" loading=\"lazy\"></a>\n  <a class=\"vs-lightbox-item vs-lightbox-item--wide\" href=\"assets/choir.webp\" data-caption=\"Quire, winter light\" data-meta=\"Cathedral nave · 24 mm · 1/30 s\">\n    <img src=\"assets/choir-sm.webp\" alt=\"Carved wooden choir stalls under tall gothic windows\" loading=\"lazy\"></a>\n  <a class=\"vs-lightbox-item vs-lightbox-item--wide\" href=\"assets/taproom.webp\" data-caption=\"Taproom at opening\" data-meta=\"Mill district · 28 mm · 1/60 s\">\n    <img src=\"assets/taproom-sm.webp\" alt=\"A long wooden bar under factory windows and hanging lamps\" loading=\"lazy\"></a>\n</div>\n\n<style>\n.vs-lightbox {\n  --vs-lightbox-radius: 14px;                    /* thumbnail corners */\n  --vs-lightbox-gap: 10px;                       /* grid gap */\n  --vs-lightbox-row: 180px;                      /* grid row height */\n  --vs-lightbox-backdrop: 14, 13, 12;            /* overlay colour as r, g, b */\n  --vs-lightbox-ink: #f4f1ec;                    /* overlay text and icons */\n  --vs-lightbox-font: \"General Sans\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n\n  display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: var(--vs-lightbox-row); gap: var(--vs-lightbox-gap);\n  font-family: var(--vs-lightbox-font);\n}\n.vs-lightbox-item {\n  position: relative; display: block; overflow: hidden; border-radius: var(--vs-lightbox-radius); cursor: zoom-in; outline: none;\n  background: #d9d4cc; isolation: isolate; -webkit-tap-highlight-color: transparent;\n  box-shadow: 0 0 0 .5px rgba(0,0,0,.08) inset;\n  transition: transform .5s cubic-bezier(.3,1.3,.5,1), box-shadow .3s cubic-bezier(.2,0,0,1);\n}\n.vs-lightbox-item--wide { grid-column: span 2; }\n.vs-lightbox-item--tall { grid-row: span 2; }\n.vs-lightbox-item--big { grid-column: span 2; grid-row: span 2; }\n.vs-lightbox-item img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .9s cubic-bezier(.16,1,.3,1), filter .4s; }\n.vs-lightbox-item::after {\n  content: \"\"; position: absolute; right: 10px; top: 10px; width: 30px; height: 30px; border-radius: 50%; z-index: 1;\n  background: rgba(255,255,255,.82) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23161412' stroke-width='1.8' stroke-linecap='round'%3E%3Cpath d='M14 4h6v6M10 20H4v-6M20 4l-6.5 6.5M4 20l6.5-6.5'/%3E%3C/svg%3E\") center / 14px no-repeat;\n  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 2px 8px rgba(0,0,0,.18);\n  opacity: 0; transform: scale(.7); transition: opacity .2s cubic-bezier(.2,0,0,1), transform .4s cubic-bezier(.3,1.6,.5,1);\n}\n.vs-lightbox-item:hover img, .vs-lightbox-item:focus-visible img { transform: scale(1.045); }\n.vs-lightbox-item:hover::after, .vs-lightbox-item:focus-visible::after { opacity: 1; transform: none; }\n.vs-lightbox-item:active { transform: scale(.975); }\n.vs-lightbox-item:focus-visible { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #161412; }\n.vs-lightbox-item.is-source { visibility: hidden; }\n\n/* overlay (moved to <body> while open) */\n.vs-lightbox-ov {\n  position: fixed; inset: 0; z-index: 2147483000; overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none;\n  font-family: var(--vs-lightbox-font); color: var(--vs-lightbox-ink); -webkit-font-smoothing: antialiased; outline: none;\n}\n.vs-lightbox-bg { position: absolute; inset: 0; background: rgb(var(--vs-lightbox-backdrop)); opacity: 0; will-change: opacity; }\n.vs-lightbox-track { position: absolute; inset: 0; will-change: transform; }\n.vs-lightbox-slide { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }\n.vs-lightbox-img {\n  position: absolute; left: 0; top: 0; max-width: none; display: block; transform-origin: 50% 50%; will-change: transform;\n  border-radius: 4px; box-shadow: 0 30px 80px -30px rgba(0,0,0,.6); -webkit-user-drag: none; cursor: zoom-in;\n}\n.vs-lightbox-ov.is-zoomed .vs-lightbox-img { cursor: grab; }\n.vs-lightbox-ov.is-grabbing .vs-lightbox-img { cursor: grabbing; }\n\n.vs-lightbox-chrome { opacity: 0; transition: opacity .25s cubic-bezier(.2,0,0,1); }\n.vs-lightbox-ov.is-ui .vs-lightbox-chrome { opacity: 1; }\n.vs-lightbox-top { position: absolute; left: 0; right: 0; top: 0; display: flex; align-items: center; gap: 6px; padding: 14px 14px 0 22px; pointer-events: none; }\n.vs-lightbox-top > * { pointer-events: auto; }\n.vs-lightbox-count { flex: 1; font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; letter-spacing: .02em; opacity: .75; }\n.vs-lightbox-btn {\n  all: unset; box-sizing: border-box; width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; cursor: pointer;\n  color: var(--vs-lightbox-ink); background: rgba(255,255,255,.08); box-shadow: 0 0 0 .5px rgba(255,255,255,.12) inset;\n  transition: background-color .15s cubic-bezier(.2,0,0,1), transform .35s cubic-bezier(.3,1.6,.5,1), opacity .2s;\n}\n.vs-lightbox-btn:hover { background: rgba(255,255,255,.16); }\n.vs-lightbox-btn:active { transform: scale(.9); }\n.vs-lightbox-btn:focus-visible { box-shadow: 0 0 0 2px var(--vs-lightbox-ink); }\n.vs-lightbox-btn[disabled] { opacity: .3; pointer-events: none; }\n.vs-lightbox-btn svg { width: 20px; height: 20px; }\n.vs-lightbox-nav { position: absolute; top: 50%; margin-top: -22px; width: 46px; height: 46px; }\n.vs-lightbox-nav.prev { left: 18px; } .vs-lightbox-nav.next { right: 18px; }\n.vs-lightbox-cap {\n  position: absolute; left: 50%; bottom: 20px; transform: translateX(-50%); max-width: min(620px, calc(100% - 32px)); text-align: center;\n  display: grid; gap: 4px; pointer-events: none;\n}\n.vs-lightbox-cap b { font-size: 15px; font-weight: 500; letter-spacing: -.005em; }\n.vs-lightbox-cap span { font-size: 12.5px; opacity: .58; letter-spacing: .01em; }\n.vs-lightbox-zoomlvl { min-width: 46px; text-align: center; font-size: 12px; font-variant-numeric: tabular-nums; opacity: .7; }\n\n@media (max-width: 600px) {\n  .vs-lightbox { grid-template-columns: repeat(2, 1fr); --vs-lightbox-row: 118px; --vs-lightbox-gap: 8px; }\n  .vs-lightbox-item--tall { grid-row: span 1; }\n  .vs-lightbox-nav, .vs-lightbox-zoomlvl { display: none; }\n  .vs-lightbox-top { padding: 10px 10px 0 18px; }\n  .vs-lightbox-cap { bottom: 16px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-lightbox-item, .vs-lightbox-item img, .vs-lightbox-item::after { transition: none; }\n}\n</style>\n\n<script>\n(() => {\n  const RM = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  const lerp = (a, b, t) => a + (b - a) * t;\n  const GAP = 28;\n  class Spring {\n    constructor(v, k = 220, z = 1) { this.v = this.t = v; this.vel = 0; this.k = k; this.z = z; }\n    to(t, k, z) { this.t = t; if (k) this.k = k; if (z) this.z = z; return this; }\n    set(v) { this.v = this.t = v; this.vel = 0; return this; }\n    step(dt) {\n      if (RM.matches) { this.v = this.t; this.vel = 0; return false; }\n      const c = 2 * Math.sqrt(this.k) * this.z, a = -this.k * (this.v - this.t) - c * this.vel;\n      this.vel += a * dt; this.v += this.vel * dt;\n      if (Math.abs(this.v - this.t) < .002 && Math.abs(this.vel) < .02) { this.v = this.t; this.vel = 0; return false; }\n      return true;\n    }\n  }\n  const ICON = {\n    close: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><path d=\"M6 6l12 12M18 6 6 18\"/></svg>',\n    prev: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14.5 5.5 8 12l6.5 6.5\"/></svg>',\n    next: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9.5 5.5 16 12l-6.5 6.5\"/></svg>',\n    zin: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><circle cx=\"10.5\" cy=\"10.5\" r=\"6\"/><path d=\"m15 15 4.5 4.5M8 10.5h5M10.5 8v5\"/></svg>',\n    zout: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><circle cx=\"10.5\" cy=\"10.5\" r=\"6\"/><path d=\"m15 15 4.5 4.5M8 10.5h5\"/></svg>',\n  };\n\n  function mount(root) {\n    if (root.__vsLightbox) return root.__vsLightbox;\n    const $ = (t, c, h) => { const e = document.createElement(t); if (c) e.className = c; if (h != null) e.innerHTML = h; return e; };\n    const items = [...root.querySelectorAll('.vs-lightbox-item')];\n    const data = items.map(a => {\n      const im = a.querySelector('img');\n      return { a, im, full: a.getAttribute('href') || a.dataset.full || im.currentSrc || im.src, title: a.dataset.caption || im.alt || '', meta: a.dataset.meta || '', loaded: false };\n    });\n    const MAX = () => parseFloat(root.dataset.maxZoom) || 3;\n    items.forEach((a, i) => a.addEventListener('click', e => { if (e.metaKey || e.ctrlKey) return; e.preventDefault(); open(i); }));\n\n    // overlay (built on first open)\n    let ov, bg, track, top, count, bZoom, zlvl, bClose, bPrev, bNext, cap, slides = new Map();\n    function build() {\n      ov = $('div', 'vs-lightbox-ov'); ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'Image viewer'); ov.tabIndex = -1;\n      ['--vs-lightbox-backdrop', '--vs-lightbox-ink', '--vs-lightbox-font'].forEach(v => { const x = getComputedStyle(root).getPropertyValue(v); if (x) ov.style.setProperty(v, x.trim()); });\n      bg = $('div', 'vs-lightbox-bg'); track = $('div', 'vs-lightbox-track');\n      top = $('div', 'vs-lightbox-top vs-lightbox-chrome'); count = $('div', 'vs-lightbox-count'); count.setAttribute('aria-live', 'polite');\n      const mk = (cls, label, html) => { const b = $('button', 'vs-lightbox-btn ' + cls, html); b.type = 'button'; b.setAttribute('aria-label', label); return b; };\n      zlvl = $('div', 'vs-lightbox-zoomlvl'); bZoom = mk('', 'Zoom in', ICON.zin); bClose = mk('', 'Close', ICON.close);\n      top.append(count, zlvl, bZoom, bClose);\n      bPrev = mk('vs-lightbox-nav prev vs-lightbox-chrome', 'Previous image', ICON.prev); bNext = mk('vs-lightbox-nav next vs-lightbox-chrome', 'Next image', ICON.next);\n      cap = $('div', 'vs-lightbox-cap vs-lightbox-chrome', '<b></b><span></span>');\n      ov.append(bg, track, cap, bPrev, bNext, top);\n      bClose.addEventListener('click', () => close());\n      bPrev.addEventListener('click', () => go(idx - 1)); bNext.addEventListener('click', () => go(idx + 1));\n      bZoom.addEventListener('click', () => zoomAt(W / 2, H / 2, S.t > 1.01 ? 1 : Math.min(2, MAX())));\n      ov.addEventListener('pointerdown', down); ov.addEventListener('pointermove', move); ov.addEventListener('pointerup', up); ov.addEventListener('pointercancel', up);\n      ov.addEventListener('wheel', wheel, { passive: false });\n      ov.addEventListener('keydown', key);\n      ov.addEventListener('dblclick', e => e.preventDefault());\n      addEventListener('resize', () => { if (isOpen) { measure(); kick(); } });\n    }\n\n    // state\n    let isOpen = false, closing = false, idx = 0, W = 0, H = 0, pads = { t: 72, b: 96, s: 80 };\n    const P = new Spring(0, 190, .84), TX = new Spring(0, 260, 1), X = new Spring(0, 260, 1), Y = new Spring(0, 260, 1), S = new Spring(1, 300, 1);\n    const DX = new Spring(0, 320, .9), DY = new Spring(0, 320, .9);\n    let thumbRect = null, raf = 0, last = 0, lastFocus = null, oldOverflow = '';\n    function measure() {\n      W = ov.clientWidth; H = ov.clientHeight; const sm = W < 600;\n      pads = sm ? { t: 64, b: 84, s: 0 } : { t: 76, b: 100, s: 84 };\n      slides.forEach((s, j) => layoutSlide(j));\n    }\n    function fit(j) {\n      const d = data[j], im = d.im, ar = (im.naturalWidth || 4) / (im.naturalHeight || 3);\n      const aw = W - pads.s * 2, ah = H - pads.t - pads.b; let w = aw, h = w / ar; if (h > ah) { h = ah; w = h * ar; }\n      return { w, h, x: (W - w) / 2, y: pads.t + (ah - h) / 2, cx: W / 2, cy: pads.t + ah / 2 };\n    }\n    function slide(j) {\n      if (j < 0 || j >= data.length) return null;\n      if (slides.has(j)) return slides.get(j);\n      const el = $('div', 'vs-lightbox-slide'), img = $('img', 'vs-lightbox-img'); const d = data[j];\n      img.alt = d.title; img.draggable = false; img.decoding = 'async'; img.src = d.im.currentSrc || d.im.src;\n      if (d.full && d.full !== img.src) { const hi = new Image(); hi.decoding = 'async'; hi.src = d.full; (hi.decode ? hi.decode() : new Promise(r => hi.onload = r)).then(() => { img.src = d.full; }).catch(() => {}); }\n      el.append(img); track.append(el); const s = { el, img }; slides.set(j, s); layoutSlide(j); return s;\n    }\n    function layoutSlide(j) {\n      const s = slides.get(j); if (!s) return; const f = fit(j);\n      s.el.style.transform = `translateX(${j * (W + GAP)}px)`;\n      Object.assign(s.img.style, { width: f.w + 'px', height: f.h + 'px', left: f.x + 'px', top: f.y + 'px' });\n      if (j !== idx) { s.img.style.transform = ''; s.img.style.clipPath = ''; }\n    }\n    function syncSlides() {\n      for (const j of [idx - 1, idx, idx + 1]) slide(j);\n      slides.forEach((s, j) => { if (Math.abs(j - idx) > 1) { s.el.remove(); slides.delete(j); } else if (j !== idx) { s.img.style.transform = ''; s.img.style.clipPath = ''; } });\n    }\n    function thumbFor(j) {\n      const r = items[j].getBoundingClientRect(), o = ov.getBoundingClientRect();\n      if (r.bottom < 0 || r.top > innerHeight || r.width === 0) return null;\n      return { x: r.left - o.left, y: r.top - o.top, w: r.width, h: r.height, r: parseFloat(getComputedStyle(items[j]).borderTopLeftRadius) || 0 };\n    }\n    function bounds(s, f) {\n      const ax = f.w * s, ay = f.h * s;\n      const bx = ax <= W ? [0, 0] : [W - f.cx - ax / 2, ax / 2 - f.cx];\n      const by = ay <= H ? [0, 0] : [H - f.cy - ay / 2, ay / 2 - f.cy];\n      return { x: bx, y: by };\n    }\n    const rubber = (v, a, b) => v < a ? a - Math.pow(a - v, .8) * .5 : v > b ? b + Math.pow(v - b, .8) * .5 : v;\n\n    function render() {\n      const p = clamp(P.v, 0, 1.2), f = fit(idx), cur = slides.get(idx); if (!cur) return;\n      track.style.transform = `translateX(${-idx * (W + GAP) + TX.v}px)`;\n      const dFrac = clamp(Math.hypot(DX.v * .4, DY.v) / (H * .55), 0, 1);\n      bg.style.opacity = String(clamp(p, 0, 1) * (1 - dFrac * .85));\n      let ox = 0, oy = 0, so = 1, clip = '', alpha = 1;\n      if (p < 1) {\n        const T = thumbRect;\n        if (T) {\n          const sc = Math.max(T.w / f.w, T.h / f.h), q = clamp(p, 0, 1);\n          so = lerp(sc, 1, q); ox = lerp(T.x + T.w / 2 - f.cx, 0, q); oy = lerp(T.y + T.h / 2 - (f.y + f.h / 2), 0, q);\n          const ix = lerp((f.w - T.w / sc) / 2, 0, q), iy = lerp((f.h - T.h / sc) / 2, 0, q);\n          const rad = lerp(T.r, 4, q) / (so * S.v);\n          clip = `inset(${iy.toFixed(2)}px ${ix.toFixed(2)}px round ${rad.toFixed(2)}px)`;\n        } else { so = lerp(.86, 1, clamp(p, 0, 1)); alpha = clamp(p, 0, 1); }\n      }\n      const ds = 1 - clamp(Math.abs(DY.v) / H, 0, .35) * .5;\n      cur.img.style.transform = `translate(${(ox + X.v + DX.v).toFixed(2)}px, ${(oy + Y.v + DY.v).toFixed(2)}px) scale(${(so * S.v * ds).toFixed(4)})`;\n      cur.img.style.clipPath = clip; cur.img.style.opacity = String(alpha);\n      cur.img.style.borderRadius = p < 1 ? '0' : '4px';\n      ov.classList.toggle('is-zoomed', S.v > 1.01);\n      zlvl.textContent = S.t > 1.01 ? Math.round(S.t * 100) + '%' : '';\n    }\n    function frame(ts) {\n      raf = 0; const dt = Math.min(.034, (ts - (last || ts)) / 1000); last = ts;\n      let moving = false; for (const s of [P, TX, X, Y, S, DX, DY]) moving = s.step(dt) || moving;\n      render();\n      if (closing && P.v <= .001 && !moving) return finish();\n      if (moving || ptrs.size) kick(); else last = 0;\n    }\n    const kick = () => { if (!raf) raf = requestAnimationFrame(frame); };\n\n    function paintUI() {\n      count.textContent = `${String(idx + 1).padStart(2, '0')} / ${String(data.length).padStart(2, '0')}`;\n      cap.querySelector('b').textContent = data[idx].title; cap.querySelector('span').textContent = data[idx].meta;\n      bPrev.disabled = idx === 0; bNext.disabled = idx === data.length - 1;\n      bZoom.innerHTML = S.t > 1.01 ? ICON.zout : ICON.zin; bZoom.setAttribute('aria-label', S.t > 1.01 ? 'Zoom out' : 'Zoom in');\n    }\n    function open(i) {\n      if (!ov) build();\n      if (isOpen && !closing) return;\n      const was = closing ? P.v : 0; if (!closing) lastFocus = document.activeElement; isOpen = true; closing = false; idx = i;\n      if (!ov.isConnected) document.body.append(ov);\n      oldOverflow = document.documentElement.style.overflow; document.documentElement.style.overflow = 'hidden';\n      measure(); syncSlides(); thumbRect = thumbFor(i);\n      items.forEach((a, j) => a.classList.toggle('is-source', j === i));\n      for (const s of [TX, X, Y, DX, DY]) s.set(0); S.set(1);\n      P.set(was).to(1, 190, .84); ov.classList.add('is-ui'); paintUI(); render();\n      ov.focus({ preventScroll: true }); root.dispatchEvent(new CustomEvent('open', { detail: { index: i } })); kick();\n    }\n    function close() {\n      if (!isOpen || closing) return; closing = true;\n      thumbRect = thumbFor(idx);\n      items.forEach((a, j) => a.classList.toggle('is-source', j === idx && !!thumbRect));\n      P.to(0, 260, 1); TX.to(0); X.to(0, 260, 1); Y.to(0, 260, 1); S.to(1, 260, 1); DX.to(0, 260, 1); DY.to(0, 260, 1);\n      ov.classList.remove('is-ui'); kick();\n    }\n    function finish() {\n      isOpen = closing = false; ov.remove(); items.forEach(a => a.classList.remove('is-source'));\n      document.documentElement.style.overflow = oldOverflow;\n      const f = items[idx]; (f || lastFocus) && (f || lastFocus).focus({ preventScroll: true });\n      root.dispatchEvent(new CustomEvent('close', { detail: { index: idx } }));\n    }\n    function go(n) {\n      if (n < 0 || n >= data.length || n === idx) { TX.to(0, 260, 1); kick(); return; }\n      const d = n - idx; TX.v += d * (W + GAP); idx = n; TX.to(0, 200, .95);\n      X.set(0); Y.set(0); S.set(1); DX.set(0); DY.set(0);\n      syncSlides(); paintUI(); kick();\n    }\n    function zoomAt(px, py, s1) {\n      const f = fit(idx); s1 = clamp(s1, 1, MAX());\n      const mx = px - f.cx, my = py - (f.y + f.h / 2), s0 = S.t;\n      let tx = mx - (mx - X.t) * (s1 / s0), ty = my - (my - Y.t) * (s1 / s0);\n      const b = bounds(s1, f); tx = clamp(tx, b.x[0], b.x[1]); ty = clamp(ty, b.y[0], b.y[1]);\n      if (s1 <= 1.001) { tx = 0; ty = 0; }\n      S.to(s1, 300, 1); X.to(tx, 300, 1); Y.to(ty, 300, 1); paintUI(); kick();\n    }\n\n    // gestures\n    const ptrs = new Map(); let g = null, lastTap = { t: 0, x: 0, y: 0 }, tapT = 0, wheelAcc = 0, wheelLock = 0;\n    const vel = { s: [] }; const track1 = (x, y) => { const t = performance.now(); vel.s.push({ t, x, y }); while (vel.s.length > 2 && t - vel.s[0].t > 90) vel.s.shift(); };\n    const velocity = () => { const a = vel.s[0], b = vel.s[vel.s.length - 1]; if (!a || b.t - a.t < 8) return { x: 0, y: 0 }; return { x: (b.x - a.x) / (b.t - a.t) * 1000, y: (b.y - a.y) / (b.t - a.t) * 1000 }; };\n    function down(e) {\n      if (closing || e.target.closest('button')) return;\n      ov.setPointerCapture(e.pointerId); ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });\n      const f = fit(idx);\n      if (ptrs.size === 2) {\n        const [a, b] = [...ptrs.values()]; const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;\n        g = { mode: 'pinch', dist: Math.hypot(a.x - b.x, a.y - b.y), m0x: mx - f.cx, m0y: my - (f.y + f.h / 2), s0: S.v, x0: X.v, y0: Y.v };\n        return;\n      }\n      g = { mode: null, sx: e.clientX, sy: e.clientY, t: performance.now(), x0: X.v, y0: Y.v, onImg: e.target.classList.contains('vs-lightbox-img'), type: e.pointerType };\n      vel.s = []; track1(e.clientX, e.clientY);\n      for (const s of [X, Y, TX, DX, DY]) { s.t = s.v; s.vel = 0; }\n      ov.classList.add('is-grabbing');\n    }\n    function move(e) {\n      if (!ptrs.has(e.pointerId) || !g) return; ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });\n      const f = fit(idx);\n      if (g.mode === 'pinch' && ptrs.size >= 2) {\n        const [a, b] = [...ptrs.values()]; const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2 - f.cx, my = (a.y + b.y) / 2 - (f.y + f.h / 2);\n        const s1 = clamp(g.s0 * d / g.dist, .7, MAX() * 1.4), ux = (g.m0x - g.x0) / g.s0, uy = (g.m0y - g.y0) / g.s0;\n        S.set(s1); X.set(mx - ux * s1); Y.set(my - uy * s1); kick(); return;\n      }\n      const dx = e.clientX - g.sx, dy = e.clientY - g.sy; track1(e.clientX, e.clientY);\n      if (!g.mode) {\n        if (Math.hypot(dx, dy) < 6) return;\n        g.mode = S.v > 1.01 ? 'pan' : Math.abs(dx) > Math.abs(dy) ? 'swipe' : 'dismiss';\n      }\n      if (g.mode === 'pan') { const b = bounds(S.v, f); X.set(rubber(g.x0 + dx, b.x[0], b.x[1])); Y.set(rubber(g.y0 + dy, b.y[0], b.y[1])); }\n      else if (g.mode === 'swipe') { const edge = (idx === 0 && dx > 0) || (idx === data.length - 1 && dx < 0); TX.set(edge ? Math.sign(dx) * Math.pow(Math.abs(dx), .78) : dx); }\n      else if (g.mode === 'dismiss') { DX.set(dx * .7); DY.set(dy); }\n      kick();\n    }\n    function up(e) {\n      if (!ptrs.has(e.pointerId)) return; ptrs.delete(e.pointerId); ov.classList.remove('is-grabbing');\n      if (!g) return;\n      const f = fit(idx);\n      if (g.mode === 'pinch') {\n        if (ptrs.size === 1) { const [p] = [...ptrs.values()]; g = { mode: 'pan', sx: p.x, sy: p.y, x0: X.v, y0: Y.v, t: 0 }; vel.s = []; return; }\n        const s1 = clamp(S.v, 1, MAX()), b = bounds(s1, f);\n        S.to(s1, 260, 1); X.to(s1 <= 1.001 ? 0 : clamp(X.v, b.x[0], b.x[1]), 260, 1); Y.to(s1 <= 1.001 ? 0 : clamp(Y.v, b.y[0], b.y[1]), 260, 1);\n        g = null; paintUI(); kick(); return;\n      }\n      const v = velocity(), mode = g.mode;\n      if (mode === 'pan') {\n        const b = bounds(S.v, f);\n        X.to(clamp(X.v + v.x * .28, b.x[0], b.x[1]), 90, 1); Y.to(clamp(Y.v + v.y * .28, b.y[0], b.y[1]), 90, 1);\n        X.vel = v.x; Y.vel = v.y;\n      } else if (mode === 'swipe') {\n        const dx = TX.v, n = dx < -W * .16 || v.x < -450 ? idx + 1 : dx > W * .16 || v.x > 450 ? idx - 1 : idx;\n        TX.vel = v.x; go(n);\n      } else if (mode === 'dismiss') {\n        if (Math.abs(DY.v) > H * .14 || Math.abs(v.y) > 700) { DX.vel = v.x; DY.vel = v.y; close(); }\n        else { DX.to(0); DY.to(0); DX.vel = v.x; DY.vel = v.y; }\n      } else {\n        // a tap\n        const now = performance.now();\n        if (now - lastTap.t < 320 && Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < 30) {\n          clearTimeout(tapT); lastTap.t = 0; zoomAt(e.clientX, e.clientY, S.t > 1.01 ? 1 : Math.min(2.5, MAX()));\n        } else {\n          lastTap = { t: now, x: e.clientX, y: e.clientY }; const onImg = g.onImg;\n          tapT = setTimeout(() => { if (!onImg) close(); else ov.classList.toggle('is-ui'); }, g.type === 'mouse' && !onImg ? 0 : 300);\n        }\n      }\n      g = null; kick();\n    }\n    function wheel(e) {\n      e.preventDefault(); if (closing) return;\n      if (!e.ctrlKey && Math.abs(e.deltaX) > Math.abs(e.deltaY) && S.t <= 1.01) {\n        wheelAcc += e.deltaX; const now = performance.now();\n        if (Math.abs(wheelAcc) > 60 && now > wheelLock) { go(idx + Math.sign(wheelAcc)); wheelAcc = 0; wheelLock = now + 450; }\n        return;\n      }\n      const k = e.ctrlKey ? .012 : .0028; zoomAt(e.clientX, e.clientY, S.t * Math.exp(-e.deltaY * k));\n    }\n    function key(e) {\n      const k = e.key;\n      if (k === 'Escape') { e.preventDefault(); close(); }\n      else if (k === 'ArrowRight') { e.preventDefault(); go(idx + 1); }\n      else if (k === 'ArrowLeft') { e.preventDefault(); go(idx - 1); }\n      else if (k === '+' || k === '=') { e.preventDefault(); zoomAt(W / 2, H / 2, S.t * 1.5); }\n      else if (k === '-') { e.preventDefault(); zoomAt(W / 2, H / 2, S.t / 1.5); }\n      else if (k === '0') { e.preventDefault(); zoomAt(W / 2, H / 2, 1); }\n      else if (k === 'Tab') {\n        const f = [...ov.querySelectorAll('button:not([disabled])')].filter(b => b.offsetParent); if (!f.length) return;\n        const i = f.indexOf(document.activeElement); e.preventDefault();\n        f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus();\n      }\n    }\n    const api = { open, close, next: () => go(idx + 1), prev: () => go(idx - 1), zoom: (s, x, y) => zoomAt(x ?? W / 2, y ?? H / 2, s), get index() { return idx; }, get isOpen() { return isOpen; } };\n    root.__vsLightbox = api; return api;\n  }\n  document.querySelectorAll('.vs-lightbox').forEach(mount);\n  window.vsLightbox = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Media",
    "tags": [
      "lightbox",
      "gallery",
      "zoom",
      "pan",
      "swipe",
      "shared element",
      "flip",
      "modal",
      "images"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#161412",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/lightbox/index.html",
    "thumbnail": "https://vividsites.app/api/component/lightbox/thumb.jpg",
    "video": "https://vividsites.app/api/component/lightbox/thumb.mp4",
    "page": "https://vividsites.app/c/lightbox"
  }
}