{
  "id": "tilted-ring-carousel",
  "kind": "element",
  "title": "Tilted ring carousel",
  "summary": "A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.",
  "url": "https://enrichr.dev/elements/tilted-ring-carousel.html",
  "section": "gallery",
  "techniques": [
    "carousel",
    "3d-css"
  ],
  "stack": [
    "Vanilla JS",
    "CSS"
  ],
  "frameworks": [
    "React",
    "Next.js",
    "Vanilla"
  ],
  "mood": [
    "clean",
    "playful"
  ],
  "intents": [
    "3D tilted image carousel",
    "spring physics photo slider",
    "carousel with dots and arrows",
    "rotateY gallery strip",
    "minimal light photo carousel"
  ],
  "status": "tested",
  "params": [
    {
      "key": "turn",
      "label": "Turn per step (deg)",
      "type": "range",
      "min": 0,
      "max": 90,
      "step": 5,
      "value": 60
    },
    {
      "key": "side",
      "label": "Size of the other photos (%)",
      "type": "range",
      "min": 50,
      "max": 100,
      "step": 1,
      "value": 85
    },
    {
      "key": "duration",
      "label": "Spring time (s)",
      "type": "range",
      "min": 0.3,
      "max": 2,
      "step": 0.05,
      "value": 0.8
    },
    {
      "key": "bounce",
      "label": "Bounce",
      "type": "range",
      "min": 0,
      "max": 0.6,
      "step": 0.05,
      "value": 0.2
    },
    {
      "key": "background",
      "label": "Background",
      "type": "color",
      "value": "#fbf8f1"
    },
    {
      "key": "ink",
      "label": "Text and controls",
      "type": "color",
      "value": "#0c1e18"
    },
    {
      "key": "accent",
      "label": "Accent (active dot)",
      "type": "color",
      "value": "#005236"
    }
  ],
  "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 \"Tilted ring carousel\" element to this project.\n\nThis is the \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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/tilted-ring-carousel.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 \"Tilted ring carousel\" 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 \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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 \"Tilted ring carousel\" element to this codebase.\n\nThis is the \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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-tilted-ring-carousel.mdc so the rule stays with the project:\n---\ndescription: How to use the Enrichr Tilted ring carousel 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 \"Tilted ring carousel\" element. Tailwind is available but this component needs no utility classes.\n\nThis is the \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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- tilted-ring-carousel.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 \"./tilted-ring-carousel\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function TiltedRingCarousel(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 \"Tilted ring carousel\" element to this React + Vite + TypeScript + Tailwind project. Do not install any packages.\n\nThis is the \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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- tilted-ring-carousel.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/TiltedRingCarousel.tsx):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./tilted-ring-carousel\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function TiltedRingCarousel(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 \"Tilted ring carousel\" element.\n\nThis is the \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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), tilted-ring-carousel.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 \"Tilted ring carousel\" element. No packages, no server code.\n\nThis is the \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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 tilted-ring-carousel.js and then main.js), style.css (the CSS below), tilted-ring-carousel.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 \"Tilted ring carousel\" 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 \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.json\n\nWhat it does: The photos sit side by side in one row, each in its own 800 px perspective. Every photo is turned around its vertical axis by 60 degrees for each step it is away from the current one, so neighbours lean in, the next ones show their mirrored backs and the ones after face you again, which reads as a ring. The current photo faces you at full size and the rest are scaled to 0.85. The row slides by one photo on a spring set by a duration of 0.8 seconds and a bounce of 0.2; each photo turns and resizes on its own softer spring (1 second, bounce 0.1). These are the springs Motion builds from a duration and a bounce: damping ratio is one minus the bounce, and the stiffness is chosen so the motion has shrunk to a thousandth after the duration. They are solved exactly every frame and keep their speed if you change direction mid-move. Captions fade and unblur in 0.3 seconds.\nAccessibility: The carousel is a labelled region with a focusable photo row: Left, Right, Home and End move it. Previous and Next are real buttons, disabled at the ends, and each dot is a labelled button that marks the current photo. The photo in front is announced with its caption in a polite live region, and the others are hidden from assistive technology. With prefers-reduced-motion the photos jump to their places and the captions swap without blurring.\n\nTunable settings:\n- Turn per step (deg): range 0 to 90, step 5, default 60\n- Size of the other photos (%): range 50 to 100, step 1, default 85\n- Spring time (s): range 0.3 to 2, step 0.05, default 0.8\n- Bounce: range 0 to 0.6, step 0.05, default 0.2\n- Background: color, default #fbf8f1\n- Text and controls: color, default #0c1e18\n- Accent (active dot): color, default #005236\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/tilted-ring-carousel.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 \"Tilted ring carousel\" 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 \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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 TiltedRingCarousel.\n2. Paste the JavaScript below into a separate code file named tilted-ring-carousel.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 \"./tilted-ring-carousel\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function TiltedRingCarousel(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(TiltedRingCarousel, {\n  turn: { type: ControlType.Number, title: \"Turn per step\", min: 0, max: 90, step: 5, defaultValue: 60 },\n  side: { type: ControlType.Number, title: \"Size of the other photos\", min: 50, max: 100, step: 1, defaultValue: 85 },\n  duration: { type: ControlType.Number, title: \"Spring time\", min: 0.3, max: 2, step: 0.05, defaultValue: 0.8 },\n  bounce: { type: ControlType.Number, title: \"Bounce\", min: 0, max: 0.6, step: 0.05, defaultValue: 0.2 },\n  background: { type: ControlType.Color, title: \"Background\", defaultValue: \"#fbf8f1\" },\n  ink: { type: ControlType.Color, title: \"Text and controls\", defaultValue: \"#0c1e18\" },\n  accent: { type: ControlType.Color, title: \"Accent (active dot)\", defaultValue: \"#005236\" },\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 \"Tilted ring carousel\" 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 \"Tilted ring carousel\" element from the Enrichr Library: A row of photos each turned in 3D by 60 degrees per step, so the strip reads like a ring seen from inside, sliding and settling on soft springs.\nPage: https://enrichr.dev/elements/tilted-ring-carousel.html\nMachine-readable spec: https://enrichr.dev/elements/tilted-ring-carousel.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-tilted-ring-carousel\"></div>, and a <script> block with the JavaScript followed by:\n   mount(document.getElementById(\"enrichr-tilted-ring-carousel\"), { ...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 carousel is a labelled region with a focusable photo row: Left, Right, Home and End move it. Previous and Next are real buttons, disabled at the ends, and each dot is a labelled button that marks the current photo. The photo in front is announced with its caption in a polite live region, and the others are hidden from assistive technology. With prefers-reduced-motion the photos jump to their places and the captions swap without blurring.",
  "performance": "Each frame writes one transform on the row and one on each photo, which the browser composites without layout. The spring loop only runs while something is still moving, and it stops off screen and in hidden tabs. A ResizeObserver sizes the photos from the box: 200 px wide on wide boxes, 120 px on narrow ones.",
  "dependencies": [],
  "codepen": "https://codepen.io/Enrichr-Dev/pen/VYpqBgX",
  "source": {
    "markup": "<div class=\"ef-trc\" role=\"region\" aria-roledescription=\"carousel\" aria-label=\"Tilted corgi carousel\">\n  <div class=\"ef-trc__stage\" tabindex=\"0\" aria-label=\"Photo row. Use the left and right arrow keys to move along it.\">\n    <div class=\"ef-trc__row\">\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-1.jpg\" alt=\"Corgi puppy with huge ears on an orange backdrop\" draggable=\"false\"></div><p class=\"ef-trc__cap\">orange backdrop</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-2.jpg\" alt=\"Smiling corgi in a field of purple and orange pansies\" draggable=\"false\"></div><p class=\"ef-trc__cap\">flower field</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-3.jpg\" alt=\"Corgi standing on a rock in front of autumn leaves\" draggable=\"false\"></div><p class=\"ef-trc__cap\">autumn rock</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-4.jpg\" alt=\"Corgi running down a dirt road at sunset\" style=\"object-position: 0% 50%\" draggable=\"false\"></div><p class=\"ef-trc__cap\">dirt road run</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-5.jpg\" alt=\"Corgi resting its chin on the floor of a white studio\" style=\"object-position: 68% 50%\" draggable=\"false\"></div><p class=\"ef-trc__cap\">nap time</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-6.jpg\" alt=\"Corgi with its tongue out in a white studio\" style=\"object-position: 48% 50%\" draggable=\"false\"></div><p class=\"ef-trc__cap\">studio stroll</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-1.jpg\" alt=\"Close up of the corgi puppy's big ears\" style=\"transform: scale(1.6); transform-origin: 50% 33%\" draggable=\"false\"></div><p class=\"ef-trc__cap\">all ears</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-2.jpg\" alt=\"Corgi paws among purple pansies\" style=\"transform: scale(1.5); transform-origin: 50% 92%\" draggable=\"false\"></div><p class=\"ef-trc__cap\">pansy patch</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-3.jpg\" alt=\"Close up of a smiling corgi in autumn sun\" style=\"transform: scale(1.6); transform-origin: 50% 46%\" draggable=\"false\"></div><p class=\"ef-trc__cap\">big smile</p></div></div>\n      <div class=\"ef-trc__slot\"><div class=\"ef-trc__slide\"><div class=\"ef-trc__frame\"><img class=\"ef-trc__img\" src=\"/img/library/corgi-6.jpg\" alt=\"Corgi walking past a tiny stone statue\" style=\"object-position: 30% 50%\" draggable=\"false\"></div><p class=\"ef-trc__cap\">tiny statue</p></div></div>\n    </div>\n  </div>\n  <div class=\"ef-trc__ctl\">\n    <button type=\"button\" class=\"ef-trc__btn ef-trc__prev\" aria-label=\"Previous photo\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m15 18-6-6 6-6\"/></svg></button>\n    <div class=\"ef-trc__dots\"></div>\n    <button type=\"button\" class=\"ef-trc__btn ef-trc__next\" aria-label=\"Next photo\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m9 18 6-6-6-6\"/></svg></button>\n  </div>\n  <p class=\"ef-trc__live\" aria-live=\"polite\" aria-atomic=\"true\"></p>\n</div>",
    "css": "/* Enrichr Library · https://enrichr.dev */\n.ef-trc { --ef-trc-bg: #fbf8f1; --ef-trc-ink: #0c1e18; --ef-trc-acc: #005236; --ef-trc-w: 200px; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 28rem; flex: 1 1 auto; overflow: hidden; border-radius: 1rem; background: var(--ef-trc-bg); color: color-mix(in srgb, var(--ef-trc-ink) 92%, var(--ef-trc-bg)); font-family: \"Parkinsans\", \"Space Grotesk\", sans-serif; user-select: none; }\n.ef-trc .ef-trc__stage { position: absolute; inset: 0; overflow: hidden; outline: none; border-radius: inherit; touch-action: pan-y; }\n.ef-trc .ef-trc__stage:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-trc-acc); }\n.ef-trc .ef-trc__row { position: absolute; left: 0; top: 0; display: flex; width: max-content; will-change: transform; }\n.ef-trc .ef-trc__slot { flex: none; width: var(--ef-trc-w); perspective: 800px; }\n.ef-trc .ef-trc__slide { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; width: var(--ef-trc-w); height: calc(var(--ef-trc-w) * 4 / 3); will-change: transform; }\n.ef-trc .ef-trc__frame { flex: none; width: 100%; height: 100%; overflow: hidden; border-radius: 0.5rem; cursor: pointer; }\n.ef-trc .ef-trc__img { display: block; width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; }\n.ef-trc .ef-trc__cap { flex: none; margin: 0; font-size: 0.75rem; line-height: 1rem; white-space: nowrap; color: var(--ef-trc-ink); opacity: 0; filter: blur(2px); transition: opacity 0.3s cubic-bezier(0.25, 0.1, 0.35, 1), filter 0.3s cubic-bezier(0.25, 0.1, 0.35, 1); will-change: opacity, filter; }\n.ef-trc.is-md .ef-trc__cap { font-size: 0.875rem; line-height: 1.25rem; }\n.ef-trc .ef-trc__slot.is-active .ef-trc__cap { opacity: 1; filter: blur(0); }\n.ef-trc .ef-trc__ctl { position: absolute; left: 50%; bottom: 1rem; z-index: 5; display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0 0.5rem; border: 1px solid color-mix(in srgb, var(--ef-trc-ink) 7%, transparent); border-radius: 9999px; background: color-mix(in srgb, var(--ef-trc-ink) 3%, transparent); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); color: color-mix(in srgb, var(--ef-trc-ink) 85%, var(--ef-trc-bg)); transform: translateX(-50%); }\n.ef-trc .ef-trc__btn { display: grid; place-items: center; margin: 0; padding: 0.5rem; border: 0; border-radius: 50%; background: none; color: inherit; cursor: pointer; }\n.ef-trc .ef-trc__btn svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }\n.ef-trc .ef-trc__btn[aria-disabled=\"true\"] { opacity: 0.35; cursor: default; }\n.ef-trc .ef-trc__dots { display: flex; align-items: center; justify-content: center; gap: 0.5rem; width: 180px; }\n.ef-trc .ef-trc__dot { position: relative; flex: none; width: 0.5rem; height: 0.5rem; margin: 0; padding: 0; border: 0; border-radius: 9999px; background: color-mix(in srgb, currentColor 30%, transparent); cursor: pointer; transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1), background-color 300ms cubic-bezier(0.4, 0, 0.2, 1); }\n.ef-trc .ef-trc__dot::before { content: \"\"; position: absolute; inset: -0.6rem -0.25rem; }\n.ef-trc .ef-trc__dot[aria-current=\"true\"] { width: 1.75rem; background: var(--ef-trc-acc); }\n.ef-trc .ef-trc__btn:focus-visible, .ef-trc .ef-trc__dot:focus-visible { outline: 2px solid var(--ef-trc-acc); outline-offset: 3px; }\n.ef-trc .ef-trc__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-trc .ef-trc__cap, .ef-trc .ef-trc__dot { transition: none; } }",
    "js": "// Enrichr Library · https://enrichr.dev\n// Tilted ring carousel: a row of 3:4 photos, each turned in 3D by 60 degrees per step away from the current one, so the\n// row reads like a ring seen from inside: neighbours lean in, the next ones show their mirrored backs, and so on. The\n// current photo faces you at full size with its caption sharp; the others are scaled to 0.85 and their captions blur out.\n// Moving slides the row by one photo on a spring (0.8 s, bounce 0.2) while each photo turns and resizes on its own spring\n// (1 s, bounce 0.1); captions fade and blur in 0.3 s. The springs are the same damped springs Motion builds from a\n// duration and a bounce, solved exactly each frame and carrying their speed when you change direction mid-move. The row has\n// two ends. Arrow buttons, the dots, a click on a photo, the left and right arrow keys, Home, End and a sideways swipe all\n// move it; the photo in front is announced. Pauses off screen. Reduced motion: photos jump to their places and captions swap.\n// mount(root, { turn (deg per step), side (size of the other photos, %), duration (seconds for the row spring),\n//               bounce (0 to 0.6), background, ink, accent })\nfunction mount(root, o) {\n  var reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  var box = root.querySelector(\".ef-trc\");\n  var stage = root.querySelector(\".ef-trc__stage\");\n  var row = root.querySelector(\".ef-trc__row\");\n  var slots = [].slice.call(root.querySelectorAll(\".ef-trc__slot\"));\n  var slides = slots.map(function (s) { return s.querySelector(\".ef-trc__slide\"); });\n  var dotsBox = root.querySelector(\".ef-trc__dots\");\n  var prev = root.querySelector(\".ef-trc__prev\"), next = root.querySelector(\".ef-trc__next\");\n  var live = root.querySelector(\".ef-trc__live\");\n  var n = Math.max(1, slots.length), active = 0, started = false;\n  var opts = {}, rowS = null, sp = [], dots = [], sw = 200;\n  var raf = 0, last = 0, visible = true, destroyed = false, drag = null, io = null, ro = null, offs = [];\n\n  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }\n  function cap(i) { var c = slides[i] && slides[i].querySelector(\".ef-trc__cap\"); return c ? c.textContent.trim() : \"\"; }\n\n  // Motion's spring from { duration, bounce }: damping ratio = 1 - bounce, natural frequency chosen so the motion has\n  // shrunk to 1/1000 of its size after the duration\n  function tune(s, dur, bounce) {\n    var z = Math.min(1, Math.max(0.05, 1 - bounce)), T = Math.min(10, Math.max(0.01, dur));\n    s.z = z;\n    s.w = z < 1 ? Math.log(z / (0.001 * Math.sqrt(1 - z * z))) / (z * T) : 9.2335 / T;\n  }\n  function spring(x, rd, rs) { return { x: x, v: 0, t: x, z: 1, w: 10, rd: rd, rs: rs }; }\n  // exact damped-spring step from the current offset and speed\n  function adv(s, dt) {\n    var d0 = s.x - s.t, v0 = s.v;\n    if (d0 === 0 && v0 === 0) return false;\n    var w = s.w, z = s.z, e, d, v;\n    if (z < 1) {\n      var wd = w * Math.sqrt(1 - z * z), c = Math.cos(wd * dt), sn = Math.sin(wd * dt);\n      e = Math.exp(-z * w * dt);\n      d = e * (d0 * c + ((v0 + z * w * d0) / wd) * sn);\n      v = e * (v0 * c - ((z * w * v0 + w * w * d0) / wd) * sn);\n    } else {\n      var B = v0 + w * d0;\n      e = Math.exp(-w * dt);\n      d = e * (d0 + B * dt); v = e * (v0 - w * B * dt);\n    }\n    s.x = s.t + d; s.v = v;\n    if (Math.abs(v) < s.rs && Math.abs(d) < s.rd) { s.x = s.t; s.v = 0; return false; }\n    return true;\n  }\n\n  function paint() {\n    row.style.transform = \"translate3d(\" + (-rowS.x * sw).toFixed(2) + \"px,0,0)\";\n    for (var i = 0; i < sp.length; i++) slides[i].style.transform = \"scale(\" + sp[i].s.x.toFixed(4) + \") rotateY(\" + sp[i].r.x.toFixed(3) + \"deg)\";\n  }\n  function targets() {\n    rowS.t = active;\n    sp.forEach(function (p, i) { p.r.t = (active - i) * opts.turn; p.s.t = i === active ? 1 : opts.side / 100; });\n    if (reduce) { rowS.x = rowS.t; rowS.v = 0; sp.forEach(function (p) { p.r.x = p.r.t; p.s.x = p.s.t; p.r.v = p.s.v = 0; }); paint(); }\n    else kick();\n  }\n  function marks() {\n    slots.forEach(function (s, i) {\n      var on = i === active;\n      s.classList.toggle(\"is-active\", on);\n      s.setAttribute(\"role\", \"group\");\n      s.setAttribute(\"aria-roledescription\", \"slide\");\n      s.setAttribute(\"aria-label\", (i + 1) + \" of \" + n);\n      if (on) s.removeAttribute(\"aria-hidden\"); else s.setAttribute(\"aria-hidden\", \"true\");\n    });\n    dots.forEach(function (d, i) { d.setAttribute(\"aria-current\", i === active ? \"true\" : \"false\"); });\n    prev.setAttribute(\"aria-disabled\", active <= 0 ? \"true\" : \"false\");\n    next.setAttribute(\"aria-disabled\", active >= n - 1 ? \"true\" : \"false\");\n  }\n  function toSlide(i, say) {\n    i = Math.max(0, Math.min(n - 1, i));\n    if (i === active) return;\n    active = i;\n    marks();\n    targets();\n    if (say) live.textContent = \"Photo \" + (active + 1) + \" of \" + n + (cap(active) ? \": \" + cap(active) : \"\");\n  }\n\n  function loop(now) {\n    raf = 0;\n    var dt = Math.min(0.064, (now - last) / 1000 || 0); last = now;\n    if (!visible || document.hidden) return; // the IntersectionObserver restarts it\n    var moving = adv(rowS, dt);\n    for (var i = 0; i < sp.length; i++) { if (adv(sp[i].r, dt)) moving = true; if (adv(sp[i].s, dt)) moving = true; }\n    paint();\n    if (moving) raf = requestAnimationFrame(loop);\n  }\n  function kick() { if (!raf && !destroyed) { last = performance.now(); raf = requestAnimationFrame(loop); } }\n\n  function size() {\n    var W = Math.max(1, box.clientWidth), H = Math.max(1, box.clientHeight), md = W >= 768;\n    sw = Math.max(70, Math.min(md ? 200 : 120, (H - 150) * 0.75));\n    var sh = sw * 4 / 3, cy = Math.min(H / 2, H - 98 - sh / 2);\n    box.classList.toggle(\"is-md\", md);\n    box.style.setProperty(\"--ef-trc-w\", sw.toFixed(2) + \"px\");\n    row.style.left = (W / 2 - sw / 2).toFixed(2) + \"px\";\n    row.style.top = (cy - sh / 2).toFixed(2) + \"px\";\n    paint();\n  }\n\n  function buildDots() {\n    dotsBox.textContent = \"\"; dots = [];\n    slots.forEach(function (s, i) {\n      var b = document.createElement(\"button\");\n      b.type = \"button\"; b.className = \"ef-trc__dot\";\n      b.setAttribute(\"aria-label\", \"Show photo \" + (i + 1) + (cap(i) ? \": \" + cap(i) : \"\"));\n      b.addEventListener(\"click\", function () { toSlide(i, true); });\n      dotsBox.appendChild(b); dots.push(b);\n    });\n  }\n\n  function onKey(e) {\n    var k = e.key;\n    if (k === \"ArrowRight\") { e.preventDefault(); toSlide(active + 1, true); }\n    else if (k === \"ArrowLeft\") { e.preventDefault(); toSlide(active - 1, true); }\n    else if (k === \"Home\") { e.preventDefault(); toSlide(0, true); }\n    else if (k === \"End\") { e.preventDefault(); toSlide(n - 1, true); }\n  }\n  function down(e) { if (e.button !== undefined && e.button !== 0) return; drag = { x: e.clientX, id: e.pointerId, moved: false }; }\n  function up(e) {\n    if (!drag || e.pointerId !== drag.id) return;\n    var dx = e.clientX - drag.x, was = drag; drag = null;\n    if (Math.abs(dx) > 40) { was.moved = true; toSlide(active + (dx < 0 ? 1 : -1), true); }\n    else {\n      var f = e.target.closest && e.target.closest(\".ef-trc__frame\");\n      if (f) { var i = slots.indexOf(f.closest(\".ef-trc__slot\")); if (i >= 0) toSlide(i, true); }\n    }\n  }\n  function cancel() { drag = null; }\n  function onPrev() { toSlide(active - 1, true); }\n  function onNext() { toSlide(active + 1, true); }\n  box.addEventListener(\"keydown\", onKey);\n  stage.addEventListener(\"pointerdown\", down);\n  stage.addEventListener(\"pointerup\", up);\n  stage.addEventListener(\"pointercancel\", cancel);\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    prev.removeEventListener(\"click\", onPrev); next.removeEventListener(\"click\", onNext);\n    dotsBox.textContent = \"\"; dots = [];\n  });\n\n  function update(partial) {\n    opts = Object.assign({ turn: 60, side: 85, duration: 0.8, bounce: 0.2, background: \"#fbf8f1\", ink: \"#0c1e18\", accent: \"#005236\" }, opts, partial || {});\n    opts.turn = num(opts.turn, 60); opts.side = Math.max(10, num(opts.side, 85));\n    opts.duration = Math.max(0.1, num(opts.duration, 0.8)); opts.bounce = Math.min(0.9, Math.max(0, num(opts.bounce, 0.2)));\n    box.style.setProperty(\"--ef-trc-bg\", opts.background);\n    box.style.setProperty(\"--ef-trc-ink\", opts.ink);\n    box.style.setProperty(\"--ef-trc-acc\", opts.accent);\n    if (destroyed) return;\n    if (!started) {\n      // first run: everything starts at rest in its place (Motion does not animate the first render)\n      started = true;\n      active = Math.min(3, n - 1); // the pen opens on the fourth photo\n      rowS = spring(active, 0.0005, 0.005);\n      sp = slides.map(function (s, i) { return { r: spring((active - i) * opts.turn, 0.02, 0.2), s: spring(i === active ? 1 : opts.side / 100, 0.0005, 0.005) }; });\n      buildDots();\n      marks();\n    }\n    tune(rowS, opts.duration, opts.bounce);\n    sp.forEach(function (p) { tune(p.r, opts.duration * 1.25, opts.bounce / 2); tune(p.s, opts.duration * 1.25, opts.bounce / 2); });\n    targets();\n    size();\n  }\n\n  update(o);\n  if (\"IntersectionObserver\" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) kick(); }); io.observe(box); }\n  if (\"ResizeObserver\" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(box); }\n  function onVis() { if (!document.hidden) kick(); }\n  document.addEventListener(\"visibilitychange\", onVis);\n  offs.push(function () { document.removeEventListener(\"visibilitychange\", onVis); });\n  return {\n    update: update,\n    destroy: function () {\n      destroyed = true; cancelAnimationFrame(raf); raf = 0;\n      offs.forEach(function (f) { f(); }); offs = [];\n      if (io) io.disconnect();\n      if (ro) ro.disconnect();\n    }\n  };\n}"
  }
}