{
  "id": "portrait-card-gallery",
  "kind": "element",
  "title": "Portrait card gallery",
  "summary": "A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.",
  "url": "https://enrichr.dev/elements/portrait-card-gallery.html",
  "section": "gallery",
  "techniques": [
    "carousel",
    "reveal"
  ],
  "stack": [
    "Vanilla JS",
    "CSS"
  ],
  "frameworks": [
    "React",
    "Next.js",
    "Vanilla"
  ],
  "mood": [
    "polished",
    "cinematic"
  ],
  "intents": [
    "9:16 story gallery",
    "vertical card carousel",
    "portrait photo slider with captions",
    "autoplay carousel with progress bar",
    "swipe gallery for reels"
  ],
  "status": "tested",
  "params": [
    {
      "key": "interval",
      "label": "Time per photo (s, 0 is off)",
      "type": "range",
      "min": 0,
      "max": 10,
      "step": 0.5,
      "value": 4.5
    },
    {
      "key": "duration",
      "label": "Slide time (s)",
      "type": "range",
      "min": 0.3,
      "max": 2.5,
      "step": 0.1,
      "value": 1.2
    },
    {
      "key": "gap",
      "label": "Card spacing (% of a card)",
      "type": "range",
      "min": 80,
      "max": 140,
      "step": 1,
      "value": 105
    },
    {
      "key": "background",
      "label": "Background",
      "type": "color",
      "value": "#0c1e18"
    },
    {
      "key": "accent",
      "label": "Accent (progress bar)",
      "type": "color",
      "value": "#e6ff55"
    }
  ],
  "license": "Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.",
  "contract": "function mount(root, opts) -> { update(partialOpts), destroy() }; root must contain the element markup",
  "exports": {
    "claude": "Add the \"Portrait card gallery\" element to this project.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSteps:\n1. Look at the project first to see its framework and where components, styles and static files live. Follow its conventions.\n2. Fetch https://enrichr.dev/elements/portrait-card-gallery.json if you can read URLs (fields: source.markup, source.css, source.js, params); otherwise use the source at the end of this message.\n3. Add the files, mount it where I tell you (default: the top of the home page), and pass the settings below as the opts.\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n5. Run the dev server and check it. with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "chatgpt": "I want the \"Portrait card gallery\" element on my website. Below is its complete source. Please:\n1. Build one self-contained index.html that I can open locally to see it working (put the CSS in a <style> tag and the JavaScript in a <script> tag, with the markup in the body).\n2. Then explain, step by step, which parts go where in my own site, and how to mount it. My stack is: [tell me yours, or say \"plain HTML\"].\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "cursor": "Add the \"Portrait card gallery\" element to this codebase.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nUse Composer / agent mode. First search the repo for how components, styles and scripts are organised, then add the files in the matching places, mount it on the page I name, and run the app to confirm it works. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nOptional: save this as .cursor/rules/enrichr-portrait-card-gallery.mdc so the rule stays with the project:\n---\ndescription: How to use the Enrichr Portrait card gallery element\nalwaysApply: false\n---\nmount(root, opts) expects the element markup inside root and returns { update, destroy }. Always call destroy() on unmount and never edit the ef- class names.",
    "v0": "Create a Next.js (App Router) client component for the \"Portrait card gallery\" element. Tailwind is available but this component needs no utility classes.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- portrait-card-gallery.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nUse this component as the wrapper (adjust the import paths):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./portrait-card-gallery\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function PortraitCardGallery(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRender it on the home page so I can see it. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "lovable": "Add the \"Portrait card gallery\" element to this React + Vite + TypeScript + Tailwind project. Do not install any packages.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- portrait-card-gallery.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nWrapper component (src/components/PortraitCardGallery.tsx):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./portrait-card-gallery\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function PortraitCardGallery(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRemove the \"use client\" line if it is not needed. Show it on the home page. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "bolt": "Create a small Vite project (vanilla JavaScript, no framework unless I already have one) that shows the \"Portrait card gallery\" element.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, then a script tag), style.css (the CSS below), portrait-card-gallery.js (the JavaScript below) and main.js that sets the container's innerHTML to the markup and calls mount(container, opts) with the settings below. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "replit": "Create a new HTML, CSS and JavaScript Repl that shows the \"Portrait card gallery\" element. No packages, no server code.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, link style.css, load portrait-card-gallery.js and then main.js), style.css (the CSS below), portrait-card-gallery.js (the JavaScript below), main.js (sets the container's innerHTML to the markup, then calls mount(container, opts) with the settings below). Press Run to preview. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "figma": "Designer handoff notes for the \"Portrait card gallery\" element (Enrichr Library). Figma cannot run this JavaScript inside a design file, so use it as a motion reference and hand the code to a developer.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nWhat it does: Every card is placed by its offset from the current one around the loop, so the row has no ends. Each step out moves a card by 105% of a card width and makes it 20% smaller and 40% fainter. A move tweens every card to its new place over 1.2 seconds on a power4 in-out curve; the card that wraps from one end to the other sweeps across behind the rest. The caption on the new centre card rises 20 px and fades in after 0.4 seconds on a power3 out curve, while the old caption drops away on power3 in. A bar under the cards fills in 4.5 seconds and then moves on. The tweens are small requestAnimationFrame timers with the same curves and the same rules as the GSAP original: a new move takes over from wherever a card is. On first load the cards spread out from a stack in the middle.\nAccessibility: The gallery is a labelled carousel region with a focusable photo row: Left, Right, Home and End move it, and Previous and Next buttons appear when it has keyboard focus. The photo in the middle is announced with its caption and description in a polite live region; the other cards are hidden from assistive technology. Autoplay pauses on hover, on focus, off screen and in hidden tabs. With prefers-reduced-motion there is no autoplay and moves jump straight to their places.\n\nTunable settings:\n- Time per photo (s, 0 is off): range 0 to 10, step 0.5, default 4.5\n- Slide time (s): range 0.3 to 2.5, step 0.1, default 1.2\n- Card spacing (% of a card): range 80 to 140, step 1, default 105\n- Background: color, default #0c1e18\n- Accent (progress bar): color, default #e6ff55\n\nCurrent settings:\n{{SETTINGS}}\n\nFor the developer: Element contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nIn Figma:\n- Draw the resting state as a frame and the end state as a second frame; link them with Smart Animate to show the motion in a prototype.\n- Put the settings above in the frame's notes or Dev Mode annotations so the developer matches them.\n- Link https://enrichr.dev/elements/portrait-card-gallery.html in the handoff: the live demo and the copy-ready code are there. Developers can paste the prompts for Claude, Cursor or v0 from the Export menu.\nNothing is copied from another designer's file; this is original code from Enrichr.",
    "framer": "Add the \"Portrait card gallery\" element to Framer as a code component. Framer code components are React, so wrap the vanilla code like this. Check it in the Framer editor, as Framer's component API can change.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. In Framer: Assets > Code > New component. Name it PortraitCardGallery.\n2. Paste the JavaScript below into a separate code file named portrait-card-gallery.js in the same project, with  export { mount };  and  export const markup = \"<the markup string>\";  added. Put the CSS into the component as a <style> tag.\n3. Component:\n```tsx\nimport { addPropertyControls, ControlType } from \"framer\"\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./portrait-card-gallery\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function PortraitCardGallery(props: any) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n\naddPropertyControls(PortraitCardGallery, {\n  interval: { type: ControlType.Number, title: \"Time per photo\", min: 0, max: 10, step: 0.5, defaultValue: 4.5 },\n  duration: { type: ControlType.Number, title: \"Slide time\", min: 0.3, max: 2.5, step: 0.1, defaultValue: 1.2 },\n  gap: { type: ControlType.Number, title: \"Card spacing\", min: 80, max: 140, step: 1, defaultValue: 105 },\n  background: { type: ControlType.Color, title: \"Background\", defaultValue: \"#0c1e18\" },\n  accent: { type: ControlType.Color, title: \"Accent (progress bar)\", defaultValue: \"#e6ff55\" },\n})\n```\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "webflow": "Add the \"Portrait card gallery\" element to a Webflow site with an Embed element. Test it on the published or preview site: scripts in embeds may not run inside the Designer canvas.\n\nThis is the \"Portrait card gallery\" element from the Enrichr Library: A looping row of tall 9:16 photo cards that glide into the centre on a slow, sleek curve, with a caption that rises in and a progress bar for autoplay.\nPage: https://enrichr.dev/elements/portrait-card-gallery.html\nMachine-readable spec: https://enrichr.dev/elements/portrait-card-gallery.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. Add an Embed element where it should appear. Paste a <style> block with the CSS, one container <div id=\"enrichr-portrait-card-gallery\"></div>, and a <script> block with the JavaScript followed by:\n   mount(document.getElementById(\"enrichr-portrait-card-gallery\"), { ...your settings... });\n   (set the container's innerHTML to the markup first, as in the contract).\n2. Embed elements have a size limit (check Webflow's current number). If the code is longer, host the JS and CSS as files and link them from Custom code in the page settings, then keep only the container and the mount call in the Embed.\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}"
  },
  "exportTools": [
    "claude",
    "chatgpt",
    "cursor",
    "v0",
    "lovable",
    "bolt",
    "replit",
    "figma",
    "framer",
    "webflow"
  ],
  "accessibility": "The gallery is a labelled carousel region with a focusable photo row: Left, Right, Home and End move it, and Previous and Next buttons appear when it has keyboard focus. The photo in the middle is announced with its caption and description in a polite live region; the other cards are hidden from assistive technology. Autoplay pauses on hover, on focus, off screen and in hidden tabs. With prefers-reduced-motion there is no autoplay and moves jump straight to their places.",
  "performance": "Moves only change transform and opacity, which the browser composites without layout. One requestAnimationFrame loop drives all the tweens and the bar, and it stops counting off screen and in hidden tabs. A ResizeObserver resizes the cards to fit the box.",
  "dependencies": [],
  "codepen": "https://codepen.io/Enrichr-Dev/pen/GgWPBPY",
  "source": {
    "markup": "<div class=\"ef-pcg\" role=\"region\" aria-roledescription=\"carousel\" aria-label=\"Corgi portrait gallery\">\n  <div class=\"ef-pcg__glow\" aria-hidden=\"true\"></div>\n  <div class=\"ef-pcg__head\">\n    <p class=\"ef-pcg__kicker\">Enrichr Library</p>\n    <p class=\"ef-pcg__sub\">A corgi edit</p>\n  </div>\n  <div class=\"ef-pcg__stage\" tabindex=\"0\" aria-label=\"Portrait gallery. Use the left and right arrow keys to move through the photos.\">\n    <figure class=\"ef-pcg__card\">\n      <img class=\"ef-pcg__img\" src=\"/img/library/corgi-1.jpg\" alt=\"Corgi puppy with huge ears on an orange backdrop\" draggable=\"false\">\n      <figcaption class=\"ef-pcg__cap\"><span class=\"ef-pcg__tag\">01 &mdash; Puppy</span> <span class=\"ef-pcg__title\">All ears</span></figcaption>\n    </figure>\n    <figure class=\"ef-pcg__card\">\n      <img class=\"ef-pcg__img\" src=\"/img/library/corgi-2.jpg\" alt=\"Smiling corgi in a field of purple and orange pansies\" draggable=\"false\">\n      <figcaption class=\"ef-pcg__cap\"><span class=\"ef-pcg__tag\">02 &mdash; Outdoors</span> <span class=\"ef-pcg__title\">Flower field</span></figcaption>\n    </figure>\n    <figure class=\"ef-pcg__card\">\n      <img class=\"ef-pcg__img\" src=\"/img/library/corgi-3.jpg\" alt=\"Corgi standing on a rock in front of autumn leaves\" draggable=\"false\">\n      <figcaption class=\"ef-pcg__cap\"><span class=\"ef-pcg__tag\">03 &mdash; Autumn</span> <span class=\"ef-pcg__title\">On the rock</span></figcaption>\n    </figure>\n    <figure class=\"ef-pcg__card\">\n      <img class=\"ef-pcg__img\" src=\"/img/library/corgi-4.jpg\" alt=\"Corgi running down a dirt road at sunset\" style=\"object-position: 18% 50%\" draggable=\"false\">\n      <figcaption class=\"ef-pcg__cap\"><span class=\"ef-pcg__tag\">04 &mdash; Friends</span> <span class=\"ef-pcg__title\">Dirt road run</span></figcaption>\n    </figure>\n    <figure class=\"ef-pcg__card\">\n      <img class=\"ef-pcg__img\" src=\"/img/library/corgi-6.jpg\" alt=\"Corgi with its tongue out in a white studio\" style=\"object-position: 48% 50%\" draggable=\"false\">\n      <figcaption class=\"ef-pcg__cap\"><span class=\"ef-pcg__tag\">05 &mdash; Studio</span> <span class=\"ef-pcg__title\">Ready to play</span></figcaption>\n    </figure>\n  </div>\n  <button type=\"button\" class=\"ef-pcg__btn ef-pcg__prev\" aria-label=\"Previous photo\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><polyline points=\"15 18 9 12 15 6\"/></svg></button>\n  <button type=\"button\" class=\"ef-pcg__btn ef-pcg__next\" aria-label=\"Next photo\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><polyline points=\"9 18 15 12 9 6\"/></svg></button>\n  <div class=\"ef-pcg__bar\" aria-hidden=\"true\"><div class=\"ef-pcg__fill\"></div></div>\n  <p class=\"ef-pcg__hint\" aria-hidden=\"true\">Swipe, click or use the arrow keys</p>\n  <p class=\"ef-pcg__live\" aria-live=\"polite\" aria-atomic=\"true\"></p>\n</div>",
    "css": "/* Enrichr Library · https://enrichr.dev */\n.ef-pcg { --ef-pcg-bg: #0c1e18; --ef-pcg-ink: #fbf8f1; --ef-pcg-acc: #e6ff55; --ef-pcg-deep: #050d0a; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 32rem; flex: 1 1 auto; overflow: hidden; border-radius: 1rem; background: var(--ef-pcg-bg); color: var(--ef-pcg-ink); font-family: \"Instrument Sans\", system-ui, sans-serif; user-select: none; }\n.ef-pcg .ef-pcg__glow { position: absolute; top: 50%; left: 50%; width: 150%; height: 150%; background: radial-gradient(circle, color-mix(in srgb, var(--ef-pcg-ink) 3%, transparent) 0%, transparent 60%); transform: translate(-50%, -50%); pointer-events: none; }\n.ef-pcg .ef-pcg__head { position: absolute; top: 2rem; left: 0; z-index: 50; width: 100%; text-align: center; text-shadow: 0 1px 10px rgba(0, 0, 0, 0.45); pointer-events: none; }\n.ef-pcg .ef-pcg__kicker { margin: 0 0 0.25rem; font: 600 0.75rem/1rem \"Parkinsans\", \"Space Grotesk\", sans-serif; letter-spacing: 0.4em; text-transform: uppercase; color: color-mix(in srgb, var(--ef-pcg-ink) 58%, var(--ef-pcg-bg)); opacity: 1; }\n.ef-pcg .ef-pcg__sub { margin: 0; font-size: 0.875rem; line-height: 1.25rem; font-weight: 400; letter-spacing: 0.5em; text-transform: uppercase; color: var(--ef-pcg-ink); opacity: 1; }\n.ef-pcg .ef-pcg__stage { position: absolute; inset: 0; z-index: 10; outline: none; border-radius: inherit; touch-action: pan-y; }\n.ef-pcg .ef-pcg__stage:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-pcg-acc); }\n.ef-pcg .ef-pcg__card { position: absolute; top: 50%; left: 50%; box-sizing: border-box; width: 360px; height: 640px; margin: 0; border-radius: 1.5rem; overflow: hidden; background: color-mix(in srgb, var(--ef-pcg-bg) 70%, var(--ef-pcg-deep)); box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.8), 0 0 0 1px color-mix(in srgb, var(--ef-pcg-ink) 10%, transparent); cursor: pointer; transform: translate(-50%, -50%); will-change: transform, opacity; }\n.ef-pcg .ef-pcg__img { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; transition: transform 0.7s ease-out; -webkit-user-drag: none; }\n.ef-pcg .ef-pcg__card:hover .ef-pcg__img { transform: scale(1.05); }\n.ef-pcg .ef-pcg__cap { position: absolute; left: 0; bottom: 0; box-sizing: border-box; width: 100%; margin: 0; padding: 2.5rem 1.5rem 1.5rem; background: linear-gradient(to top, color-mix(in srgb, var(--ef-pcg-deep) 90%, transparent) 0%, transparent 100%); opacity: 0; transform: translateY(20px); }\n.ef-pcg .ef-pcg__tag { display: block; margin: 0 0 0.25rem; font-size: 0.75rem; line-height: 1rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: color-mix(in srgb, var(--ef-pcg-ink) 80%, transparent); }\n.ef-pcg .ef-pcg__title { display: block; margin: 0; font: 700 1.5rem/2rem \"Parkinsans\", \"Space Grotesk\", sans-serif; letter-spacing: -0.025em; color: var(--ef-pcg-ink); }\n.ef-pcg .ef-pcg__bar { position: absolute; bottom: 2rem; left: 50%; z-index: 50; width: 200px; height: 2px; overflow: hidden; border-radius: 2px; background: color-mix(in srgb, var(--ef-pcg-ink) 10%, transparent); transform: translateX(-50%); }\n.ef-pcg .ef-pcg__fill { width: 0%; height: 100%; background: var(--ef-pcg-acc); box-shadow: 0 0 10px color-mix(in srgb, var(--ef-pcg-acc) 50%, transparent); }\n.ef-pcg .ef-pcg__hint { position: absolute; bottom: 1rem; left: 0; z-index: 50; width: 100%; margin: 0; text-align: center; font-size: 10px; line-height: 1; letter-spacing: 0.1em; text-transform: uppercase; color: color-mix(in srgb, var(--ef-pcg-ink) 45%, var(--ef-pcg-bg)); opacity: 1; pointer-events: none; }\n.ef-pcg.is-still .ef-pcg__bar { display: none; }\n/* Prev and Next stay out of sight until they get keyboard focus (the pen itself has no arrows) */\n.ef-pcg .ef-pcg__btn { position: absolute; top: 50%; z-index: 60; display: grid; place-items: center; box-sizing: content-box; width: 24px; height: 24px; margin: 0; padding: 0.9rem; border: 0; border-radius: 50%; background: color-mix(in srgb, var(--ef-pcg-ink) 12%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); color: var(--ef-pcg-ink); cursor: pointer; transform: translateY(-50%); opacity: 0; pointer-events: none; }\n.ef-pcg:has(:focus-visible) .ef-pcg__btn, .ef-pcg .ef-pcg__btn:focus-visible { opacity: 1; pointer-events: auto; }\n.ef-pcg .ef-pcg__btn:focus-visible { outline: 3px solid var(--ef-pcg-acc); outline-offset: 3px; }\n.ef-pcg .ef-pcg__prev { left: 1.25rem; }\n.ef-pcg .ef-pcg__next { right: 1.25rem; }\n.ef-pcg .ef-pcg__btn svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }\n.ef-pcg .ef-pcg__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n@media (prefers-reduced-motion: reduce) { .ef-pcg .ef-pcg__img { transition: none; } .ef-pcg .ef-pcg__card:hover .ef-pcg__img { transform: none; } }",
    "js": "// Enrichr Library · https://enrichr.dev\n// Portrait card gallery: tall 9:16 photo cards in a row, the current one in the middle at full size with its caption, the\n// others stepped out by 105% of a card width, each step 20% smaller and 40% fainter, on a loop (a card that wraps sweeps\n// across behind the others). A move tweens every card's offset, scale and fade over 1.2 s on a power4 in-out curve; the\n// caption of the new centre card rises in after 0.4 s (0.8 s, power3 out) while the old one drops away (0.6 s, power3 in).\n// A thin bar fills over 4.5 s and then moves on. Click a side card, swipe, use the arrow keys, Home and End, or the Previous\n// and Next buttons that appear when the gallery has keyboard focus; the photo in the middle is announced.\n// Autoplay pauses on hover, on focus, off screen and in hidden tabs. Reduced motion: no autoplay, moves jump without tweens.\n// mount(root, { interval (seconds per photo, 0 is off), duration (seconds per move), gap (% of a card width between cards),\n//               background, accent })\nfunction mount(root, o) {\n  var reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  var box = root.querySelector(\".ef-pcg\");\n  var stage = root.querySelector(\".ef-pcg__stage\");\n  var fill = root.querySelector(\".ef-pcg__fill\");\n  var live = root.querySelector(\".ef-pcg__live\");\n  var prev = root.querySelector(\".ef-pcg__prev\"), next = root.querySelector(\".ef-pcg__next\");\n  var items = [].slice.call(root.querySelectorAll(\".ef-pcg__card\")).map(function (el) {\n    return { el: el, capEl: el.querySelector(\".ef-pcg__cap\"), card: { v: { x: -50, s: 1, o: 1 }, tw: null, q: null }, cap: { v: { o: 0, y: 20 }, tw: null, q: null } };\n  });\n  var n = Math.max(1, items.length), idx = 0;\n  var opts = {}, clock = 0, last = 0, raf = 0, apStart = 0, swiped = false, drag = null;\n  var hover = false, focused = false, visible = true, destroyed = false, io = null, ro = null, offs = [];\n\n  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }\n  // GSAP eases: power4 is a quintic curve, power3 a quartic one\n  function p4io(p) { return p < 0.5 ? 16 * p * p * p * p * p : 1 - Math.pow(-2 * p + 2, 5) / 2; }\n  function p3o(p) { return 1 - Math.pow(1 - p, 4); }\n  function p3i(p) { return p * p * p * p; }\n\n  // a tween replaces the running one on the same channel; a delayed one waits and then takes over from where things are\n  function to(ch, target, dur, ease, delay) {\n    var t = { to: target, d: dur, e: ease, start: clock + (delay || 0), from: null };\n    if (delay > 0) ch.q = t; else { ch.q = null; ch.tw = t; }\n  }\n  function run(ch) {\n    if (ch.q && clock >= ch.q.start) { ch.tw = ch.q; ch.q = null; }\n    var t = ch.tw;\n    if (!t) return !!ch.q;\n    if (!t.from) { t.from = {}; for (var k in t.to) t.from[k] = ch.v[k]; }\n    var p = t.d > 0 ? Math.min(1, Math.max(0, (clock - t.start) / t.d)) : 1, e = t.e(p);\n    for (var j in t.to) ch.v[j] = t.from[j] + (t.to[j] - t.from[j]) * e;\n    if (p >= 1) ch.tw = null;\n    return true;\n  }\n  function paint(it) {\n    var c = it.card.v, q = it.cap.v;\n    it.el.style.transform = \"translate(\" + c.x.toFixed(3) + \"%,-50%) scale(\" + c.s.toFixed(4) + \")\";\n    it.el.style.opacity = c.o.toFixed(4);\n    it.capEl.style.opacity = q.o.toFixed(4);\n    it.capEl.style.transform = \"translateY(\" + q.y.toFixed(2) + \"px)\";\n  }\n\n  function layout(instant) {\n    var half = Math.floor(n / 2);\n    items.forEach(function (it, i) {\n      var off = i - idx;\n      if (off > half) off -= n; else if (off < -half) off += n; // loop: the far card wraps to the other side\n      var a = Math.abs(off);\n      var t = { x: -50 + off * opts.gap, s: Math.max(0, 1 - a * 0.2), o: Math.max(0, 1 - a * 0.4) };\n      it.el.style.zIndex = String(10 - a);\n      if (instant) {\n        it.card.tw = it.card.q = it.cap.tw = it.cap.q = null;\n        it.card.v = t; it.cap.v = off === 0 ? { o: 1, y: 0 } : { o: 0, y: 20 };\n        paint(it);\n      } else {\n        to(it.card, t, opts.duration, p4io, 0);\n        if (off === 0) to(it.cap, { o: 1, y: 0 }, 0.8, p3o, 0.4);\n        else to(it.cap, { o: 0, y: 20 }, 0.6, p3i, 0);\n      }\n      it.el.setAttribute(\"role\", \"group\");\n      it.el.setAttribute(\"aria-roledescription\", \"slide\");\n      it.el.setAttribute(\"aria-label\", (i + 1) + \" of \" + n);\n      if (off === 0) it.el.removeAttribute(\"aria-hidden\"); else it.el.setAttribute(\"aria-hidden\", \"true\");\n    });\n  }\n  function still() { return reduce || !(opts.interval > 0); }\n  function startAuto() { apStart = clock; fill.style.width = \"0%\"; }\n  function say() {\n    var it = items[idx], img = it.el.querySelector(\"img\");\n    var cap = [].map.call(it.el.querySelectorAll(\".ef-pcg__tag, .ef-pcg__title\"), function (e) { return e.textContent.trim(); }).filter(Boolean).join(\", \");\n    live.textContent = \"Photo \" + (idx + 1) + \" of \" + n + (cap ? \": \" + cap : \"\") + (img && img.alt ? \". \" + img.alt : \"\");\n  }\n  function show(i, user) {\n    idx = ((i % n) + n) % n;\n    layout(reduce);\n    startAuto();\n    if (user) say();\n  }\n  function navigate(dir, user) { show(idx + dir, user); }\n\n  function loop(now) {\n    raf = requestAnimationFrame(loop);\n    var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;\n    if (!visible || document.hidden) return;\n    clock += dt;\n    for (var i = 0; i < items.length; i++) {\n      var a = run(items[i].card), b = run(items[i].cap);\n      if (a || b) paint(items[i]);\n    }\n    if (!still()) {\n      if (hover || focused) apStart += dt; // hold the bar where it is\n      var p = (clock - apStart) / opts.interval;\n      fill.style.width = (Math.min(1, p) * 100).toFixed(2) + \"%\";\n      if (p >= 1) navigate(1, false);\n    }\n  }\n\n  function size() {\n    var W = Math.max(1, box.clientWidth), H = Math.max(1, box.clientHeight);\n    var cw = Math.min(W * 0.75, W >= 768 ? 360 : 320, H * 0.889 * 9 / 16);\n    items.forEach(function (it) { it.el.style.width = cw.toFixed(2) + \"px\"; it.el.style.height = (cw * 16 / 9).toFixed(2) + \"px\"; });\n  }\n\n  function onKey(e) {\n    var k = e.key;\n    if (k === \"ArrowRight\") { e.preventDefault(); navigate(1, true); }\n    else if (k === \"ArrowLeft\") { e.preventDefault(); navigate(-1, true); }\n    else if (k === \"Home\") { e.preventDefault(); show(0, true); }\n    else if (k === \"End\") { e.preventDefault(); show(n - 1, true); }\n  }\n  function down(e) { if (e.button !== undefined && e.button !== 0) return; drag = { x: e.clientX, id: e.pointerId }; }\n  function up(e) {\n    if (!drag || e.pointerId !== drag.id) return;\n    var dx = e.clientX - drag.x; drag = null;\n    if (Math.abs(dx) > 50) { swiped = true; setTimeout(function () { swiped = false; }, 0); navigate(dx < 0 ? 1 : -1, true); }\n  }\n  function cancel() { drag = null; }\n  function onEnter(e) { if (e.pointerType === \"mouse\") hover = true; }\n  function onLeave() { hover = false; }\n  function onFocusIn() { focused = true; }\n  function onFocusOut(e) { if (!box.contains(e.relatedTarget)) focused = false; }\n  function onPrev() { navigate(-1, true); }\n  function onNext() { navigate(1, true); }\n  items.forEach(function (it, i) {\n    function onClick() { if (!swiped && i !== idx) show(i, true); }\n    it.el.addEventListener(\"click\", onClick);\n    offs.push(function () { it.el.removeEventListener(\"click\", onClick); });\n  });\n  box.addEventListener(\"keydown\", onKey);\n  stage.addEventListener(\"pointerdown\", down);\n  stage.addEventListener(\"pointerup\", up);\n  stage.addEventListener(\"pointercancel\", cancel);\n  box.addEventListener(\"pointerenter\", onEnter);\n  box.addEventListener(\"pointerleave\", onLeave);\n  box.addEventListener(\"focusin\", onFocusIn);\n  box.addEventListener(\"focusout\", onFocusOut);\n  prev.addEventListener(\"click\", onPrev);\n  next.addEventListener(\"click\", onNext);\n  offs.push(function () {\n    box.removeEventListener(\"keydown\", onKey);\n    stage.removeEventListener(\"pointerdown\", down); stage.removeEventListener(\"pointerup\", up); stage.removeEventListener(\"pointercancel\", cancel);\n    box.removeEventListener(\"pointerenter\", onEnter); box.removeEventListener(\"pointerleave\", onLeave);\n    box.removeEventListener(\"focusin\", onFocusIn); box.removeEventListener(\"focusout\", onFocusOut);\n    prev.removeEventListener(\"click\", onPrev); next.removeEventListener(\"click\", onNext);\n  });\n\n  function update(partial) {\n    var was = opts.interval, wasGap = opts.gap;\n    opts = Object.assign({ interval: 4.5, duration: 1.2, gap: 105, background: \"#0c1e18\", accent: \"#e6ff55\" }, opts, partial || {});\n    opts.interval = Math.max(0, num(opts.interval, 4.5));\n    opts.duration = Math.max(0.05, num(opts.duration, 1.2));\n    opts.gap = Math.max(40, num(opts.gap, 105));\n    box.style.setProperty(\"--ef-pcg-bg\", opts.background);\n    box.style.setProperty(\"--ef-pcg-acc\", opts.accent);\n    box.classList.toggle(\"is-still\", still());\n    if (destroyed) return;\n    if (was !== undefined && was !== opts.interval) startAuto();\n    size();\n    if (wasGap !== undefined && wasGap !== opts.gap) layout(reduce); // re-target with the new spacing\n  }\n\n  update(o);\n  // the pen starts with every card stacked in the middle and lets them spread out on the first move\n  items.forEach(paint);\n  layout(reduce);\n  startAuto();\n  if (\"IntersectionObserver\" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }\n  if (\"ResizeObserver\" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(box); }\n  last = performance.now(); raf = requestAnimationFrame(loop);\n  return {\n    update: update,\n    destroy: function () {\n      destroyed = true; cancelAnimationFrame(raf);\n      offs.forEach(function (f) { f(); }); offs = [];\n      if (io) io.disconnect();\n      if (ro) ro.disconnect();\n    }\n  };\n}"
  }
}