{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stories",
  "type": "registry:block",
  "title": "Story Viewer",
  "description": "A story viewer on native scroll snap: segmented progress bars, tap zones to step, press and hold to pause, and neighbours that shrink and dim beside the active story, or fold into a cube on phones.",
  "docs": "Story Viewer was added to public/vividsites/stories.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/stories",
  "categories": [
    "ui",
    "media"
  ],
  "files": [
    {
      "path": "components/vividsites/stories.html",
      "type": "registry:file",
      "target": "public/vividsites/stories.html",
      "content": "<!--\nvs-stories: a story viewer. Accounts sit side by side in a native scroll-snap strip: on wide\n  screens the neighbours shrink and dim either side of the one playing (click one to jump to\n  it); when the viewer is only one card wide, swiping turns the strip into a cube. Segmented\n  bars fill as each slide plays and the photo slowly settles in. Tap the left third to go back,\n  the rest to go on; press and hold to pause, and the chrome gets out of the way. At the end of\n  an account it moves to the next and marks the ring as seen. A like button pops with a burst.\n  Keys (viewer focused): ←/→ step, Space pause.\n\n  Markup:  <div class=\"vs-stories\" data-duration=\"5000\">\n             <section class=\"vs-stories-group\" data-name=\"account\" data-avatar=\"av.jpg\" data-time=\"2h\">\n               <figure class=\"vs-stories-slide\"><img src=\"...\" alt=\"...\"><figcaption>Optional sticker</figcaption></figure> ...\n             </section> ...\n           </div>\n  Attributes on .vs-stories: data-duration (ms per slide, default 5000), data-start (account to\n    open on, default 0; earlier rings show as seen).\n  CSS variables: --vs-stories-h (card height; width is 9:16), -radius, -ink, -ring (unseen ring\n    gradient), -font. Below the card width + 60px the viewer switches to full-width cube mode.\n  Events: \"change\" with detail { group, slide }.\n  JS: el.__vsStories.next() / prev() / goTo(group) / pause() / play() / like(); vsStories.mount(el).\n  Reduced motion: no cube, zoom or burst; bars still fill.\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=Zen+Kaku+Gothic+New:wght@400;700&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-stories\" data-duration=\"5000\" data-start=\"1\" aria-label=\"Stories\">\n  <section class=\"vs-stories-group\" data-name=\"halden.bakehouse\" data-avatar=\"assets/a-halden.webp\" data-time=\"2h\">\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-oven.webp\" alt=\"Loaves going into a wood-fired oven\"><figcaption>Proofing since 5 a.m.</figcaption></figure>\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-grower.webp\" alt=\"A grower carrying seedlings through a glasshouse\" loading=\"lazy\"><figcaption>Greens from Tove's glasshouse</figcaption></figure>\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-carrots.webp\" alt=\"A hand pulling carrots from dark soil\" loading=\"lazy\"><figcaption></figcaption></figure>\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-rind.webp\" alt=\"The mottled rind of a cave-aged cheese\" loading=\"lazy\"><figcaption>Nine months in the cave</figcaption></figure>\n  </section>\n  <section class=\"vs-stories-group\" data-name=\"maison.attar\" data-avatar=\"assets/a-attar.webp\" data-time=\"5h\">\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-attar.webp\" alt=\"An amber perfume bottle on a travertine plinth\" loading=\"lazy\"><figcaption>No. 9 is back</figcaption></figure>\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-lamp.webp\" alt=\"An angled lamp throwing a long shadow\" loading=\"lazy\"><figcaption></figcaption></figure>\n  </section>\n  <section class=\"vs-stories-group\" data-name=\"northline.guides\" data-avatar=\"assets/a-northline.webp\" data-time=\"9h\">\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-peak.webp\" alt=\"A granite peak above the clouds\" loading=\"lazy\"><figcaption>Summit window: 06:40</figcaption></figure>\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-skins.webp\" alt=\"A skier climbing with skis on the pack\" loading=\"lazy\"><figcaption></figcaption></figure>\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-monolith.webp\" alt=\"A concrete hut in the mist\" loading=\"lazy\"><figcaption>Hut to hut, day 3</figcaption></figure>\n  </section>\n  <section class=\"vs-stories-group\" data-name=\"saltwater.club\" data-avatar=\"assets/a-saltwater.webp\" data-time=\"1d\">\n    <figure class=\"vs-stories-slide\"><img src=\"assets/s-bay.webp\" alt=\"A swimmer crossing a green tidal pool\" loading=\"lazy\"><figcaption>11°C and worth it</figcaption></figure>\n  </section>\n</div>\n\n<style>\n.vs-stories {\n  --vs-stories-h: 640px;                         /* card height; width follows 9:16 */\n  --vs-stories-radius: 18px;\n  --vs-stories-ink: #ffffff;                     /* text and icons over the story */\n  --vs-stories-ring: linear-gradient(45deg, #ffb35c, #ff5e62 45%, #d9366f 75%, #8b3dff); /* unseen ring */\n  --vs-stories-font: \"Zen Kaku Gothic New\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n\n  position: relative; width: 100%; font-family: var(--vs-stories-font); color: var(--vs-stories-ink); outline: none;\n  -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;\n  --w: calc(var(--vs-stories-h) * 9 / 16);\n}\n.vs-stories *, .vs-stories *::before, .vs-stories *::after { box-sizing: border-box; }\n.vs-stories-scroller {\n  display: flex; align-items: center; gap: 22px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain;\n  scroll-snap-type: x mandatory; padding: 16px max(0px, calc((100% - var(--w)) / 2)); scrollbar-width: none;\n}\n.vs-stories-scroller::-webkit-scrollbar { display: none; }\n.vs-stories.is-full .vs-stories-scroller { gap: 0; padding-left: 0; padding-right: 0; }\n.vs-stories-card {\n  position: relative; flex: none; width: var(--w); height: var(--vs-stories-h); scroll-snap-align: center; scroll-snap-stop: always;\n  border-radius: var(--vs-stories-radius); overflow: hidden; background: #111; isolation: isolate; will-change: transform;\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.1) inset, 0 40px 80px -30px rgba(0,0,0,.6), 0 12px 30px -12px rgba(0,0,0,.4);\n  user-select: none; -webkit-user-select: none;\n}\n.vs-stories.is-full .vs-stories-card { width: 100%; border-radius: var(--vs-stories-radius); }\n.vs-stories-card::after { content: \"\"; position: absolute; inset: 0; z-index: 9; background: #000; opacity: var(--dim, 0); pointer-events: none; }\n.vs-stories-card:not(.is-active) { cursor: pointer; }\n.vs-stories-slide { position: absolute; inset: 0; margin: 0; opacity: 0; transition: opacity .25s cubic-bezier(.2,0,0,1); }\n.vs-stories-slide.is-on { opacity: 1; }\n.vs-stories-slide img { width: 100%; height: 100%; object-fit: cover; display: block; -webkit-user-drag: none; transform: scale(1.04); transition: transform 6s linear; }\n.vs-stories-slide.is-on img { transform: scale(1); }\n.vs-stories.is-paused .vs-stories-slide.is-on img, .vs-stories-card:not(.is-active) .vs-stories-slide img { transition: none; }\n.vs-stories-slide figcaption {\n  position: absolute; left: 50%; top: 58%; transform: translate(-50%, 0) rotate(-2deg); z-index: 2; max-width: 80%; text-align: center;\n  padding: 8px 14px 9px; border-radius: 10px; font-size: 17px; font-weight: 700; line-height: 1.2; letter-spacing: -.01em;\n  color: #16120e; background: #fdf8ef; box-shadow: 0 10px 24px -10px rgba(0,0,0,.5); text-wrap: balance;\n}\n.vs-stories-slide figcaption:empty { display: none; }\n\n/* chrome */\n.vs-stories-shade { position: absolute; inset: 0; z-index: 3; pointer-events: none;\n  background: linear-gradient(to bottom, rgba(0,0,0,.45), rgba(0,0,0,0) 22%, rgba(0,0,0,0) 78%, rgba(0,0,0,.42)); }\n.vs-stories-chrome { position: absolute; inset: 0; z-index: 5; pointer-events: none; transition: opacity .25s cubic-bezier(.2,0,0,1); }\n.vs-stories.is-held .vs-stories-card.is-active .vs-stories-chrome, .vs-stories.is-held .vs-stories-card.is-active .vs-stories-shade { opacity: 0; }\n.vs-stories-bars { position: absolute; left: 10px; right: 10px; top: 10px; display: flex; gap: 4px; }\n.vs-stories-bars span { position: relative; flex: 1; height: 2.5px; border-radius: 2px; overflow: hidden; background: rgba(255,255,255,.35); }\n.vs-stories-bars i { position: absolute; inset: 0; background: var(--vs-stories-ink); transform-origin: 0 50%; transform: scaleX(0); border-radius: 2px; }\n.vs-stories-head { position: absolute; left: 12px; right: 8px; top: 22px; display: flex; align-items: center; gap: 9px; }\n.vs-stories-av { width: 34px; height: 34px; border-radius: 50%; padding: 2px; background: var(--vs-stories-ring); flex: none; }\n.vs-stories-av img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; display: block; border: 2px solid #111; }\n.vs-stories-card.is-seen .vs-stories-av { background: rgba(255,255,255,.4); }\n.vs-stories-name { font-size: 14px; font-weight: 700; letter-spacing: -.005em; text-shadow: 0 1px 6px rgba(0,0,0,.35); }\n.vs-stories-time { font-size: 13px; opacity: .7; margin-left: -3px; }\n.vs-stories-sp { flex: 1; }\n.vs-stories-icon {\n  all: unset; box-sizing: border-box; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; cursor: pointer; pointer-events: auto;\n  color: var(--vs-stories-ink); transition: background-color .15s, transform .35s cubic-bezier(.3,1.6,.5,1);\n}\n.vs-stories-icon:hover { background: rgba(255,255,255,.14); }\n.vs-stories-icon:active { transform: scale(.88); }\n.vs-stories-icon:focus-visible { box-shadow: 0 0 0 2px var(--vs-stories-ink); }\n.vs-stories-icon svg { width: 20px; height: 20px; filter: drop-shadow(0 1px 3px rgba(0,0,0,.35)); }\n.vs-stories-foot { position: absolute; left: 12px; right: 8px; bottom: 12px; display: flex; align-items: center; gap: 6px; }\n.vs-stories-reply {\n  flex: 1; height: 42px; border-radius: 99px; display: flex; align-items: center; padding: 0 16px; font-size: 13.5px; pointer-events: auto; cursor: text;\n  box-shadow: 0 0 0 1px rgba(255,255,255,.55) inset; color: rgba(255,255,255,.85); background: rgba(0,0,0,.08);\n  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.vs-stories-heart.is-on { color: #ff3b5c; }\n.vs-stories-heart.is-on svg path { fill: currentColor; }\n.vs-stories-burst { position: absolute; inset: 0; pointer-events: none; }\n.vs-stories-burst i { position: absolute; left: 50%; top: 50%; width: 4px; height: 4px; margin: -2px; border-radius: 50%; background: #ff3b5c; }\n\n/* tap zones */\n.vs-stories-tap { position: absolute; top: 64px; bottom: 64px; z-index: 4; border: 0; padding: 0; background: none; cursor: pointer; outline: none; }\n.vs-stories-tap.prev { left: 0; width: 32%; } .vs-stories-tap.next { right: 0; width: 68%; }\n.vs-stories-card:not(.is-active) .vs-stories-tap, .vs-stories-card:not(.is-active) .vs-stories-chrome > * { pointer-events: none; }\n.vs-stories-card:not(.is-active) .vs-stories-foot, .vs-stories-card:not(.is-active) .vs-stories-head .vs-stories-icon { opacity: 0; }\n.vs-stories-card:not(.is-active) .vs-stories-head { top: auto; bottom: 0; left: 0; right: 0; height: 100%; flex-direction: column; justify-content: center; gap: 10px; }\n.vs-stories-card:not(.is-active) .vs-stories-av { width: 64px; height: 64px; padding: 3px; }\n.vs-stories-card:not(.is-active) .vs-stories-sp, .vs-stories-card:not(.is-active) .vs-stories-time { display: none; }\n.vs-stories-card:not(.is-active) .vs-stories-name { font-size: 15px; }\n.vs-stories-card:not(.is-active) .vs-stories-bars { opacity: 0; }\n\n/* side arrows (desktop) */\n.vs-stories-arrow {\n  all: unset; position: absolute; top: 50%; z-index: 10; width: 34px; height: 34px; margin-top: -17px; border-radius: 50%; cursor: pointer; display: grid; place-items: center;\n  color: #111; background: rgba(255,255,255,.92); box-shadow: 0 4px 14px -4px rgba(0,0,0,.4); transition: transform .35s cubic-bezier(.3,1.6,.5,1), opacity .2s;\n}\n.vs-stories-arrow:hover { transform: scale(1.1); } .vs-stories-arrow:active { transform: scale(.92); }\n.vs-stories-arrow:focus-visible { box-shadow: 0 0 0 3px rgba(255,255,255,.6); }\n.vs-stories-arrow.prev { left: calc(50% - var(--w) / 2 - 50px); } .vs-stories-arrow.next { right: calc(50% - var(--w) / 2 - 50px); }\n.vs-stories-arrow[disabled] { opacity: 0; pointer-events: none; }\n.vs-stories-arrow svg { width: 16px; height: 16px; }\n.vs-stories.is-full .vs-stories-arrow { display: none; }\n\n@media (prefers-reduced-motion: reduce) {\n  .vs-stories-slide, .vs-stories-slide img { transition: none !important; }\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 SVG = {\n    pause: '<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"6\" y=\"5\" width=\"4\" height=\"14\" rx=\"1.2\"/><rect x=\"14\" y=\"5\" width=\"4\" height=\"14\" rx=\"1.2\"/></svg>',\n    play: '<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M8 5.6c0-.8.9-1.3 1.6-.8l9 6.4c.6.4.6 1.2 0 1.6l-9 6.4c-.7.5-1.6 0-1.6-.8z\"/></svg>',\n    heart: '<svg viewBox=\"0 0 24 24\"><path d=\"M12 20.2s-7.5-4.4-7.5-10.1A4.2 4.2 0 0 1 12 7.6a4.2 4.2 0 0 1 7.5 2.5c0 5.7-7.5 10.1-7.5 10.1z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linejoin=\"round\"/></svg>',\n    send: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linejoin=\"round\"><path d=\"M20.5 3.5 10 14M20.5 3.5 14 20.5l-4-6.5-6.5-4z\"/></svg>',\n    left: '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10 3 5 8l5 5\"/></svg>',\n    right: '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m6 3 5 5-5 5\"/></svg>',\n  };\n\n  function mount(root) {\n    if (root.__vsStories) return root.__vsStories;\n    const $ = (t, c, h) => { const e = document.createElement(t); if (c) e.className = c; if (h != null) e.innerHTML = h; return e; };\n    root.tabIndex = root.tabIndex >= 0 ? root.tabIndex : 0; root.setAttribute('role', 'region'); root.setAttribute('aria-roledescription', 'stories');\n    const scroller = $('div', 'vs-stories-scroller');\n    const groups = [...root.querySelectorAll(':scope > .vs-stories-group')].map((sec, gi) => {\n      const card = $('div', 'vs-stories-card'); card.setAttribute('role', 'group');\n      const name = sec.dataset.name || '', time = sec.dataset.time || '';\n      card.setAttribute('aria-label', `Story by ${name}`);\n      const slides = [...sec.querySelectorAll('.vs-stories-slide')];\n      slides.forEach(s => card.append(s));\n      const bars = $('div', 'vs-stories-bars', slides.map(() => '<span><i></i></span>').join(''));\n      const head = $('div', 'vs-stories-head', `<span class=\"vs-stories-av\"><img src=\"${sec.dataset.avatar || ''}\" alt=\"\"></span><span class=\"vs-stories-name\">${name}</span><span class=\"vs-stories-time\">${time}</span><span class=\"vs-stories-sp\"></span>`);\n      const pause = $('button', 'vs-stories-icon', SVG.pause); pause.type = 'button'; pause.setAttribute('aria-label', 'Pause'); head.append(pause);\n      const foot = $('div', 'vs-stories-foot', `<span class=\"vs-stories-reply\">Reply to ${name}…</span>`);\n      const heart = $('button', 'vs-stories-icon vs-stories-heart', SVG.heart + '<span class=\"vs-stories-burst\"></span>'); heart.type = 'button'; heart.setAttribute('aria-label', 'Like'); heart.setAttribute('aria-pressed', 'false');\n      const send = $('button', 'vs-stories-icon', SVG.send); send.type = 'button'; send.setAttribute('aria-label', 'Share');\n      foot.append(heart, send);\n      const chrome = $('div', 'vs-stories-chrome'); chrome.append(bars, head, foot);\n      const tapP = $('button', 'vs-stories-tap prev'), tapN = $('button', 'vs-stories-tap next');\n      tapP.type = tapN.type = 'button'; tapP.tabIndex = tapN.tabIndex = -1; tapP.setAttribute('aria-label', 'Previous'); tapN.setAttribute('aria-label', 'Next');\n      card.append($('div', 'vs-stories-shade'), tapP, tapN, chrome);\n      scroller.append(card); sec.remove();\n      const g = { card, slides, fills: [...bars.querySelectorAll('i')], i: 0, pause, heart, tapP, tapN };\n      pause.addEventListener('click', e => { e.stopPropagation(); setPaused(!paused); });\n      heart.addEventListener('click', e => { e.stopPropagation(); like(g); });\n      card.addEventListener('click', e => { if (active !== gi) { e.stopPropagation(); goGroup(gi); } }, true);\n      bindTap(g, tapP, -1); bindTap(g, tapN, 1);\n      return g;\n    });\n    root.append(scroller);\n    const arrowP = $('button', 'vs-stories-arrow prev', SVG.left), arrowN = $('button', 'vs-stories-arrow next', SVG.right);\n    arrowP.type = arrowN.type = 'button'; arrowP.setAttribute('aria-label', 'Previous story'); arrowN.setAttribute('aria-label', 'Next story');\n    root.append(arrowP, arrowN);\n    arrowP.addEventListener('click', () => step(-1)); arrowN.addEventListener('click', () => step(1));\n    const live = $('div'); live.setAttribute('aria-live', 'polite'); live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)'; root.append(live);\n\n    let active = 0, t = 0, paused = false, held = false, raf = 0, last = 0, vis = true, full = false;\n    const dur = () => (parseFloat(root.dataset.duration) || 5000) / 1000;\n\n    function show(g, i) {\n      g.i = clamp(i, 0, g.slides.length - 1);\n      g.slides.forEach((s, k) => s.classList.toggle('is-on', k === g.i));\n      const nx = g.slides[g.i + 1]; if (nx) { const im = nx.querySelector('img'); if (im && im.loading === 'lazy') im.loading = 'eager'; }\n      paintBars(g, 0);\n    }\n    function paintBars(g, p) { g.fills.forEach((f, k) => f.style.transform = `scaleX(${k < g.i ? 1 : k === g.i ? p : 0})`); }\n    function step(d) {\n      const g = groups[active]; const n = g.i + d;\n      if (n < 0) { if (active > 0) goGroup(active - 1, true); else { t = 0; show(g, 0); } }\n      else if (n >= g.slides.length) { g.card.classList.add('is-seen'); if (active < groups.length - 1) goGroup(active + 1); else { t = 1; paintBars(g, 1); setPaused(true); } }\n      else { t = 0; show(g, n); live.textContent = `${g.i + 1} of ${g.slides.length}`; }\n      kick();\n    }\n    function goGroup(gi, fromEnd) {\n      const g = groups[gi]; if (!g) return;\n      if (fromEnd) g.i = g.slides.length - 1;\n      const c = g.card; scroller.scrollTo({ left: c.offsetLeft - (scroller.clientWidth - c.offsetWidth) / 2, behavior: RM.matches ? 'auto' : 'smooth' });\n      activate(gi);\n    }\n    function activate(gi) {\n      if (gi === active && groups[gi].card.classList.contains('is-active')) return;\n      active = gi; t = 0;\n      groups.forEach((g, k) => { g.card.classList.toggle('is-active', k === gi); g.card.setAttribute('aria-hidden', String(k !== gi)); });\n      show(groups[gi], groups[gi].i >= groups[gi].slides.length - 1 && groups[gi].card.classList.contains('is-seen') ? 0 : groups[gi].i);\n      arrowP.disabled = gi === 0 && groups[gi].i === 0; arrowN.disabled = false;\n      if (paused && !held) setPaused(false);\n      root.dispatchEvent(new CustomEvent('change', { detail: { group: gi, slide: groups[gi].i } })); kick();\n    }\n    function setPaused(v) {\n      paused = v; root.classList.toggle('is-paused', v);\n      groups.forEach(g => { g.pause.innerHTML = v ? SVG.play : SVG.pause; g.pause.setAttribute('aria-label', v ? 'Play' : 'Pause'); });\n      kick();\n    }\n    function like(g) {\n      const on = g.heart.getAttribute('aria-pressed') !== 'true'; g.heart.setAttribute('aria-pressed', String(on)); g.heart.classList.toggle('is-on', on);\n      if (!on || RM.matches) return;\n      g.heart.querySelector('svg').animate([{ transform: 'scale(1)' }, { transform: 'scale(.7)', offset: .2 }, { transform: 'scale(1.35)', offset: .55 }, { transform: 'scale(1)' }], { duration: 560, easing: 'cubic-bezier(.2,0,0,1)' });\n      const burst = g.heart.querySelector('.vs-stories-burst'); burst.textContent = '';\n      for (let k = 0; k < 8; k++) {\n        const dot = $('i'); burst.append(dot); const a = k / 8 * Math.PI * 2, r = 20 + (k % 2) * 6;\n        dot.animate([{ transform: 'translate(0,0) scale(1)', opacity: 1 }, { transform: `translate(${Math.cos(a) * r}px,${Math.sin(a) * r}px) scale(.2)`, opacity: 0 }], { duration: 520, easing: 'cubic-bezier(.16,1,.3,1)', fill: 'forwards' });\n      }\n    }\n    // tap zones: tap = step, press-and-hold = pause (chrome hides), a scroll steals the press\n    function bindTap(g, el, d) {\n      let downAt = 0, holdT = 0, cancelled = false;\n      el.addEventListener('pointerdown', e => {\n        if (e.button > 0) return; downAt = performance.now(); cancelled = false;\n        holdT = setTimeout(() => { held = true; root.classList.add('is-held'); kick(); }, 190);\n      });\n      const up = () => {\n        clearTimeout(holdT); const wasHeld = held; held = false; root.classList.remove('is-held');\n        if (cancelled || !downAt) return; downAt = 0;\n        if (!wasHeld) step(d); kick();\n      };\n      el.addEventListener('pointerup', up);\n      el.addEventListener('pointercancel', () => { cancelled = true; clearTimeout(holdT); held = false; root.classList.remove('is-held'); kick(); });\n      el.addEventListener('pointerleave', () => { if (downAt) { cancelled = true; clearTimeout(holdT); held = false; root.classList.remove('is-held'); } });\n      el.addEventListener('contextmenu', e => e.preventDefault());\n    }\n\n    // scroll: which card is centred, plus the neighbour styling (scale/dim, or a cube when full-width)\n    function onScroll() {\n      const vw = scroller.clientWidth, mid = scroller.scrollLeft + vw / 2; let best = 0, bd = Infinity;\n      groups.forEach((g, k) => {\n        const c = g.card, cw = c.offsetWidth, cx = c.offsetLeft + cw / 2, d = (cx - mid) / (cw + (full ? 0 : 22));\n        if (Math.abs(d) < bd) { bd = Math.abs(d); best = k; }\n        if (RM.matches) { c.style.transform = ''; c.style.setProperty('--dim', Math.abs(d) > .5 ? .45 : 0); return; }\n        if (full) {\n          const a = clamp(d, -1, 1); c.style.transformOrigin = a < 0 ? '100% 50%' : '0% 50%';\n          c.style.transform = `perspective(1100px) rotateY(${(a * 82).toFixed(2)}deg)`; c.style.setProperty('--dim', (Math.abs(a) * .6).toFixed(3));\n        } else {\n          const a = Math.min(Math.abs(d), 1.4); c.style.transformOrigin = '50% 50%';\n          c.style.transform = `scale(${(1 - Math.min(a, 1) * .28).toFixed(4)})`; c.style.setProperty('--dim', (Math.min(a, 1) * .42).toFixed(3));\n        }\n      });\n      return best;\n    }\n    let settleT = 0;\n    scroller.addEventListener('scroll', () => { const b = onScroll(); clearTimeout(settleT); settleT = setTimeout(() => activate(b), 90); }, { passive: true });\n\n    function frame(ts) {\n      raf = 0; const dt = Math.min(.05, (ts - (last || ts)) / 1000); last = ts;\n      const g = groups[active]; if (!g) return;\n      if (!paused && !held && vis) { t += dt / dur(); if (t >= 1) { t = 0; step(1); } else paintBars(g, t); }\n      if (!paused && !held && vis) raf = requestAnimationFrame(frame); else last = 0;\n    }\n    const kick = () => { if (!raf) raf = requestAnimationFrame(frame); };\n\n    root.addEventListener('keydown', e => {\n      if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }\n      else if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }\n      else if (e.key === ' ' && !e.target.closest('button')) { e.preventDefault(); setPaused(!paused); }\n    });\n    function measure() {\n      const wasFull = full, h = groups[0] ? groups[0].card.offsetHeight : 640; full = root.clientWidth < h * 9 / 16 + 60;\n      root.classList.toggle('is-full', full); onScroll();\n      if (wasFull !== full) groups[active] && scroller.scrollTo({ left: groups[active].card.offsetLeft - (scroller.clientWidth - groups[active].card.offsetWidth) / 2 });\n    }\n    new ResizeObserver(measure).observe(root);\n    new IntersectionObserver(([en]) => { vis = en.isIntersecting; if (vis) kick(); }, { threshold: .25 }).observe(root);\n    measure(); groups.forEach(g => show(g, 0)); active = -1;\n    const start = clamp(parseInt(root.dataset.start, 10) || 0, 0, groups.length - 1);\n    for (let k = 0; k < start; k++) groups[k].card.classList.add('is-seen');\n    if (start) { const c = groups[start].card; scroller.scrollLeft = c.offsetLeft - (scroller.clientWidth - c.offsetWidth) / 2; }\n    activate(start); onScroll();\n\n    const api = { next: () => step(1), prev: () => step(-1), goTo: gi => goGroup(gi), pause: () => setPaused(true), play: () => setPaused(false), like: () => like(groups[active]), get group() { return active; } };\n    root.__vsStories = api; return api;\n  }\n  document.querySelectorAll('.vs-stories').forEach(mount);\n  window.vsStories = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Media",
    "tags": [
      "stories",
      "viewer",
      "scroll snap",
      "progress",
      "tap",
      "swipe",
      "cube",
      "social",
      "media"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#ff5e62",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/stories/index.html",
    "thumbnail": "https://vividsites.app/api/component/stories/thumb.jpg",
    "video": "https://vividsites.app/api/component/stories/thumb.mp4",
    "page": "https://vividsites.app/c/stories"
  }
}