{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel",
  "type": "registry:ui",
  "title": "Carousel",
  "description": "A slideshow built on native CSS scroll-snap (snap-x mandatory; each slide snap-center). WAI-ARIA carousel/slide roles, real <button> prev/next that scroll by one slide; touch, trackpad and keyboard scrolling are the platform's.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/carousel.tsx",
      "type": "registry:ui",
      "target": "components/ui/carousel.tsx",
      "content": "/** @jsxImportSource hono/jsx */\nimport type { PropsWithChildren } from \"hono/jsx\"\nimport { cn, type ClassValue } from \"@/registry/lib/cn\"\n\n// Carousel — shadcn-htmx, htmx v4 + Tailwind v4.\n//\n// shadcn/ui ships a Carousel built on the Embla JS engine. We don't copy that\n// React code; we mirror its anatomy (Carousel / CarouselContent / CarouselItem\n// / CarouselPrevious / CarouselNext) and translate it to a NATIVE, no-engine\n// implementation. Source of truth for the API shape:\n//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/carousel.tsx (anatomy only)\n//\n// Accessibility contract follows the WAI-ARIA APG carousel pattern (Basic\n// carousel: previous/next controls, no auto-rotation):\n//   repos/aria-practices/content/patterns/carousel/carousel-pattern.html\n//   repos/aria-practices/content/patterns/carousel/examples/carousel-1-prev-next.html\n//\n// How this differs from Embla / shadcn:\n//   - The scroller is a plain horizontally-overflowing element using CSS\n//     scroll-snap (`snap-x snap-mandatory`, each slide `snap-center`). Scrolling\n//     itself is therefore native: mouse wheel, trackpad, touch swipe, and the\n//     browser's own keyboard scrolling all work with zero JS, and the snap\n//     points keep slides aligned. Tailwind v4 ships these utilities natively\n//     (repos/tailwindcss/.../src/utilities.ts: snap-x, snap-mandatory,\n//     snap-center, scroll-smooth, overscroll-x-contain; motion-safe variant in\n//     src/variants.ts).\n//   - The only thing the platform does NOT give us is \"advance by exactly one\n//     slide when the Prev/Next buttons are pressed\" + the buttons' disabled\n//     state at the ends. public/site.js (keyed on data-slot=\"carousel\") owns\n//     that: it calls Element.scrollBy() with the slide width\n//     (repos/mdn/files/en-us/web/api/element/scrollby/index.md).\n//\n// APG roles/states/properties (Basic carousel):\n//   - Root container: role=\"group\" + aria-roledescription=\"carousel\" + an\n//     accessible name via aria-label / aria-labelledby. Because the\n//     roledescription is \"carousel\", the label must NOT contain the word\n//     \"carousel\". (carousel-pattern.html, \"Basic carousel elements\")\n//   - Each slide: role=\"group\" + aria-roledescription=\"slide\" + an accessible\n//     name. When slides have no unique name, \"N of M\" is the sanctioned\n//     fallback because group elements don't support aria-setsize/aria-posinset.\n//   - The element wrapping the slides has aria-atomic=\"false\" + aria-live.\n//     For a non-auto-rotating carousel APG specifies aria-live=\"polite\".\n//   - Prev/Next are real <button>s (recommended) with aria-controls pointing\n//     at the slides wrapper.\n//\n// Composition (matches shadcn's API):\n//   <Carousel id=\"gallery\" ariaLabel=\"Featured photos\">\n//     <CarouselContent>\n//       <CarouselItem><img …/></CarouselItem>\n//       <CarouselItem><img …/></CarouselItem>\n//     </CarouselContent>\n//     <CarouselPrevious />\n//     <CarouselNext />\n//   </Carousel>\n\nconst containerBase = \"group/carousel relative\"\n\n// The scroller. `snap-x snap-mandatory` turns the row into a snap container;\n// `motion-safe:scroll-smooth` makes scrollBy() animate, but only when the user\n// has NOT requested reduced motion — panning a full-width region is a\n// vestibular-motion trigger, so under prefers-reduced-motion:reduce we drop the\n// smooth animation (motion-safe = @media (prefers-reduced-motion: no-preference);\n// repos/mdn/files/en-us/web/css/reference/at-rules/@media/prefers-reduced-motion).\n// `overflow-x-auto` lets native touch/wheel scrolling work; `overscroll-x-contain`\n// stops a swipe past the first/last slide from chain-scrolling the page or\n// triggering browser back-swipe / pull-to-refresh\n// (repos/mdn/files/en-us/web/css/reference/properties/overscroll-behavior).\n// `scrollbar-none` is the Tailwind v4 utility for scrollbar-width:none\n// (Firefox/standards); app/styles/input.css adds the WebKit ::-webkit-scrollbar\n// supplement so the bar is hidden in Chrome/Safari too. The region stays fully\n// scrollable + keyboard-reachable.\nconst contentBase =\n  \"flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain motion-safe:scroll-smooth scrollbar-none \" +\n  \"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-lg\"\n\n// Each slide centres on the snap line and never shrinks below its basis.\nconst itemBase = \"min-w-0 shrink-0 grow-0 basis-full snap-center\"\n\n// Prev/Next share the outline icon-button look so the pair reads as a unit.\nconst navBase =\n  \"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none \" +\n  \"hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 \" +\n  \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 \" +\n  \"disabled:pointer-events-none disabled:opacity-40 \" +\n  \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\ntype CarouselProps = PropsWithChildren<{\n  // Required so site.js can scope its handlers per carousel and so the\n  // Prev/Next buttons can aria-control the slides wrapper by id.\n  id: string\n  // Accessible name. APG: since aria-roledescription is \"carousel\", the name\n  // must NOT include the word \"carousel\".\n  ariaLabel?: string\n  ariaLabelledby?: string\n  class?: ClassValue\n  // htmx + arbitrary attributes ride onto the root.\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function Carousel(props: CarouselProps) {\n  const { id, ariaLabel, ariaLabelledby, class: className, children, ...rest } = props as any\n  // Boot script: runs synchronously after the element is parsed, before paint.\n  //   - Give every slide its \"N of M\" aria-label (the APG fallback name) and\n  //     wire aria-controls on the Prev/Next buttons to the scroller id.\n  //   - Set the Prev button disabled at the start (we open on slide 1).\n  // The live scroll/disabled contract lives in public/site.js; this just makes\n  // the initial render correct and a11y-complete with no flicker.\n  const boot = `(function(el){\n    var content = el.querySelector('[data-slot=\"carousel-content\"]');\n    if (content){\n      if (!content.id) content.id = el.id + '-content';\n      var items = content.querySelectorAll('[data-slot=\"carousel-item\"]');\n      var total = items.length;\n      items.forEach(function(it, i){\n        if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);\n      });\n      el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){\n        b.setAttribute('aria-controls', content.id);\n      });\n      var prev = el.querySelector('[data-carousel-prev]');\n      if (prev) prev.disabled = content.scrollLeft <= 0;\n    }\n    el.setAttribute('data-carousel-ready','true');\n  })(document.currentScript.previousElementSibling);`\n  return (\n    <>\n      <section\n        id={id}\n        data-slot=\"carousel\"\n        data-carousel\n        role=\"group\"\n        aria-roledescription=\"carousel\"\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        class={cn(containerBase, className)}\n        {...rest}\n      >\n        {children}\n      </section>\n      <script\n        // biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot\n        dangerouslySetInnerHTML={{ __html: boot }}\n      />\n    </>\n  )\n}\n\ntype CarouselContentProps = PropsWithChildren<{\n  class?: ClassValue\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function CarouselContent(props: CarouselContentProps) {\n  const { class: className, children, ...rest } = props as any\n  // The slides wrapper. APG: aria-atomic=\"false\" + aria-live=\"polite\" for a\n  // carousel that does NOT auto-rotate, so changing slides is announced.\n  // tabindex=\"0\" makes the scroll region keyboard-focusable so the browser's\n  // built-in arrow/Page scrolling reaches it (a scrollable region needs a tab\n  // stop to be operable by keyboard-only users).\n  return (\n    <div\n      data-slot=\"carousel-content\"\n      aria-atomic=\"false\"\n      aria-live=\"polite\"\n      tabindex={0}\n      class={cn(contentBase, className)}\n      {...rest}\n    >\n      {children}\n    </div>\n  )\n}\n\ntype CarouselItemProps = PropsWithChildren<{\n  class?: ClassValue\n  // Optional explicit accessible name for the slide. If omitted, the boot\n  // script assigns the APG \"N of M\" fallback.\n  ariaLabel?: string\n  ariaLabelledby?: string\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function CarouselItem(props: CarouselItemProps) {\n  const { class: className, ariaLabel, ariaLabelledby, children, ...rest } = props as any\n  // Each slide is role=\"group\" + aria-roledescription=\"slide\". The label is\n  // either provided here or filled in as \"N of M\" by the boot script.\n  return (\n    <div\n      data-slot=\"carousel-item\"\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={ariaLabel}\n      aria-labelledby={ariaLabelledby}\n      class={cn(itemBase, className)}\n      {...rest}\n    >\n      {children}\n    </div>\n  )\n}\n\ntype CarouselNavProps = PropsWithChildren<{\n  class?: ClassValue\n  ariaLabel?: string\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\n// Default chevron icons (inline SVG so the snippet has no icon-lib dependency).\nfunction ChevronLeft() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"m15 18-6-6 6-6\" />\n    </svg>\n  )\n}\nfunction ChevronRight() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"m9 18 6-6-6-6\" />\n    </svg>\n  )\n}\n\nexport function CarouselPrevious(props: CarouselNavProps) {\n  const { class: className, ariaLabel = \"Previous slide\", children, ...rest } = props as any\n  // Real <button>: role + Space/Enter activation + disabled come from the\n  // platform. Activating it does NOT move focus (APG note), so users can\n  // repeatedly click/Enter to keep advancing.\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"carousel-previous\"\n      data-carousel-prev\n      aria-label={ariaLabel}\n      class={cn(navBase, \"absolute top-1/2 -left-3 -translate-y-1/2\", className)}\n      {...rest}\n    >\n      {children ?? <ChevronLeft />}\n    </button>\n  )\n}\n\nexport function CarouselNext(props: CarouselNavProps) {\n  const { class: className, ariaLabel = \"Next slide\", children, ...rest } = props as any\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"carousel-next\"\n      data-carousel-next\n      aria-label={ariaLabel}\n      class={cn(navBase, \"absolute top-1/2 -right-3 -translate-y-1/2\", className)}\n      {...rest}\n    >\n      {children ?? <ChevronRight />}\n    </button>\n  )\n}\n"
    },
    {
      "path": "registry/jinja2/carousel.html",
      "type": "registry:file",
      "target": "templates/components/carousel.html",
      "content": "{# Carousel macros — shadcn-htmx, htmx v4 + Tailwind v4.\n   Mirrors registry/ui/carousel.tsx. Native CSS scroll-snap slideshow with a\n   role=\"group\" aria-roledescription=\"carousel\" container, role=\"group\"\n   aria-roledescription=\"slide\" items, and real <button> prev/next controls.\n   The boot <script> right after the wrapper fills in each slide's \"N of M\"\n   label and the initial disabled state; public/site.js owns the live\n   scrollBy() + disabled contract (keyed on data-slot=\"carousel\").\n\n   Usage:\n     {% from \"components/carousel.html\" import carousel, carousel_content_open, carousel_content_close, carousel_item, carousel_previous, carousel_next %}\n\n     {% call carousel(id=\"gallery\", aria_label=\"Featured photos\") %}\n       {{ carousel_content_open() }}\n         {% call(_) carousel_item() %}<img src=\"…\" alt=\"…\">{% endcall %}\n         {% call(_) carousel_item() %}<img src=\"…\" alt=\"…\">{% endcall %}\n       {{ carousel_content_close() }}\n       {{ carousel_previous() }}\n       {{ carousel_next() }}\n     {% endcall %} #}\n\n{% macro carousel(id, aria_label=none, aria_labelledby=none, extra_class=\"\", attrs={}) %}\n<section id=\"{{ id }}\"\n         data-slot=\"carousel\"\n         data-carousel\n         role=\"group\"\n         aria-roledescription=\"carousel\"\n         {%- if aria_label %} aria-label=\"{{ aria_label }}\"{% endif %}\n         {%- if aria_labelledby %} aria-labelledby=\"{{ aria_labelledby }}\"{% endif %}\n         class=\"group/carousel relative {{ extra_class }}\"\n         {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}=\"{{ v }}\"{% endfor %}>\n  {{ caller() }}\n</section>\n<script>(function(el){\n  var content = el.querySelector('[data-slot=\"carousel-content\"]');\n  if (content){\n    if (!content.id) content.id = el.id + '-content';\n    var items = content.querySelectorAll('[data-slot=\"carousel-item\"]');\n    var total = items.length;\n    items.forEach(function(it, i){\n      if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);\n    });\n    el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){\n      b.setAttribute('aria-controls', content.id);\n    });\n    var prev = el.querySelector('[data-carousel-prev]');\n    if (prev) prev.disabled = content.scrollLeft <= 0;\n  }\n  el.setAttribute('data-carousel-ready','true');\n})(document.currentScript.previousElementSibling);</script>\n{% endmacro %}\n\n{% macro carousel_content_open(extra_class=\"\") -%}\n<div data-slot=\"carousel-content\"\n     aria-atomic=\"false\"\n     aria-live=\"polite\"\n     tabindex=\"0\"\n     {# motion-safe:scroll-smooth → smooth pan only when prefers-reduced-motion is not reduce (vestibular trigger); overscroll-x-contain → no scroll-chaining/back-swipe past the ends. MDN: prefers-reduced-motion, overscroll-behavior. #}\n     class=\"flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain motion-safe:scroll-smooth scrollbar-none focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-lg {{ extra_class }}\">\n{%- endmacro %}\n{% macro carousel_content_close() %}</div>{% endmacro %}\n\n{% macro carousel_item(aria_label=none, aria_labelledby=none, extra_class=\"\") %}\n<div data-slot=\"carousel-item\"\n     role=\"group\"\n     aria-roledescription=\"slide\"\n     {%- if aria_label %} aria-label=\"{{ aria_label }}\"{% endif %}\n     {%- if aria_labelledby %} aria-labelledby=\"{{ aria_labelledby }}\"{% endif %}\n     class=\"min-w-0 shrink-0 grow-0 basis-full snap-center {{ extra_class }}\">\n  {{ caller() }}\n</div>\n{% endmacro %}\n\n{% macro carousel_previous(aria_label=\"Previous slide\", extra_class=\"\") %}\n<button type=\"button\"\n        data-slot=\"carousel-previous\"\n        data-carousel-prev\n        aria-label=\"{{ aria_label }}\"\n        class=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 absolute top-1/2 -left-3 -translate-y-1/2 {{ extra_class }}\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m15 18-6-6 6-6\"/></svg></button>\n{% endmacro %}\n\n{% macro carousel_next(aria_label=\"Next slide\", extra_class=\"\") %}\n<button type=\"button\"\n        data-slot=\"carousel-next\"\n        data-carousel-next\n        aria-label=\"{{ aria_label }}\"\n        class=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 absolute top-1/2 -right-3 -translate-y-1/2 {{ extra_class }}\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m9 18 6-6-6-6\"/></svg></button>\n{% endmacro %}\n"
    },
    {
      "path": "registry/go-templates/carousel.tmpl",
      "type": "registry:file",
      "target": "components/carousel.tmpl",
      "content": "{{/*\n  Carousel template — shadcn-htmx, htmx v4 + Tailwind v4.\n  Mirrors registry/ui/carousel.tsx. Native CSS scroll-snap slideshow.\n  Provides named templates:\n    - \"carousel\"           — the role=\"group\" carousel wrapper + boot script\n    - \"carousel_content\"   — the scroll-snap slides wrapper (compose items in .Body)\n    - \"carousel_item\"      — one role=\"group\" aria-roledescription=\"slide\"\n    - \"carousel_previous\"  — the prev <button>\n    - \"carousel_next\"      — the next <button>\n\n  Usage (compose the inner HTML so you can build the slide list):\n\n      {{template \"carousel\" (dict \"ID\" \"gallery\" \"AriaLabel\" \"Featured photos\"\n        \"Body\" (htmlSafe (printf `%s%s%s`\n          (... carousel_content with items ...)\n          (... carousel_previous ...)\n          (... carousel_next ...))))}}\n\n  The boot <script> fills each slide's \"N of M\" label + initial disabled state;\n  public/site.js owns the live scrollBy() + disabled contract.\n*/}}\n\n{{define \"carousel\"}}\n<section id=\"{{.ID}}\"\n         data-slot=\"carousel\"\n         data-carousel\n         role=\"group\"\n         aria-roledescription=\"carousel\"\n         {{- if .AriaLabel}} aria-label=\"{{.AriaLabel}}\"{{end}}\n         {{- if .AriaLabelledby}} aria-labelledby=\"{{.AriaLabelledby}}\"{{end}}\n         class=\"group/carousel relative\">\n  {{.Body}}\n</section>\n<script>(function(el){\n  var content = el.querySelector('[data-slot=\"carousel-content\"]');\n  if (content){\n    if (!content.id) content.id = el.id + '-content';\n    var items = content.querySelectorAll('[data-slot=\"carousel-item\"]');\n    var total = items.length;\n    items.forEach(function(it, i){\n      if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);\n    });\n    el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){\n      b.setAttribute('aria-controls', content.id);\n    });\n    var prev = el.querySelector('[data-carousel-prev]');\n    if (prev) prev.disabled = content.scrollLeft <= 0;\n  }\n  el.setAttribute('data-carousel-ready','true');\n})(document.currentScript.previousElementSibling);</script>\n{{end}}\n\n{{define \"carousel_content\"}}\n<div data-slot=\"carousel-content\"\n     aria-atomic=\"false\"\n     aria-live=\"polite\"\n     tabindex=\"0\"\n     {{/* motion-safe:scroll-smooth → smooth pan only when prefers-reduced-motion is not reduce (vestibular trigger); overscroll-x-contain → no scroll-chaining/back-swipe past the ends. MDN: prefers-reduced-motion, overscroll-behavior. */}}\n     class=\"flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain motion-safe:scroll-smooth scrollbar-none focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-lg\">{{.Body}}</div>\n{{end}}\n\n{{define \"carousel_item\"}}\n<div data-slot=\"carousel-item\"\n     role=\"group\"\n     aria-roledescription=\"slide\"\n     {{- if .AriaLabel}} aria-label=\"{{.AriaLabel}}\"{{end}}\n     {{- if .AriaLabelledby}} aria-labelledby=\"{{.AriaLabelledby}}\"{{end}}\n     class=\"min-w-0 shrink-0 grow-0 basis-full snap-center\">{{.Body}}</div>\n{{end}}\n\n{{define \"carousel_previous\"}}\n{{- $label := or .AriaLabel \"Previous slide\" -}}\n<button type=\"button\"\n        data-slot=\"carousel-previous\"\n        data-carousel-prev\n        aria-label=\"{{$label}}\"\n        class=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 absolute top-1/2 -left-3 -translate-y-1/2\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m15 18-6-6 6-6\"/></svg></button>\n{{end}}\n\n{{define \"carousel_next\"}}\n{{- $label := or .AriaLabel \"Next slide\" -}}\n<button type=\"button\"\n        data-slot=\"carousel-next\"\n        data-carousel-next\n        aria-label=\"{{$label}}\"\n        class=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 absolute top-1/2 -right-3 -translate-y-1/2\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m9 18 6-6-6-6\"/></svg></button>\n{{end}}\n"
    },
    {
      "path": "registry/phoenix/carousel.ex",
      "type": "registry:file",
      "target": "lib/my_app_web/components/carousel.ex",
      "content": "defmodule ShadcnHtmx.Components.Carousel do\n  @moduledoc \"\"\"\n  Carousel — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.\n\n  Mirrors registry/ui/carousel.tsx. A native CSS scroll-snap slideshow:\n  five function components — `carousel`, `carousel_content`, `carousel_item`,\n  `carousel_previous`, `carousel_next`. The scroll itself is native\n  (snap-x / snap-mandatory / snap-center); public/site.js owns the live\n  scrollBy() + Prev/Next disabled contract (keyed on data-slot=\"carousel\").\n\n  Accessibility follows the WAI-ARIA APG Carousel pattern (Basic carousel):\n  role=\"group\" + aria-roledescription=\"carousel\" on the container, role=\"group\"\n  + aria-roledescription=\"slide\" on each item, real <button> prev/next.\n\n  ## Examples\n\n      <.carousel id=\"gallery\" aria-label=\"Featured photos\">\n        <.carousel_content>\n          <.carousel_item><img src=\"…\" alt=\"…\" /></.carousel_item>\n          <.carousel_item><img src=\"…\" alt=\"…\" /></.carousel_item>\n        </.carousel_content>\n        <.carousel_previous />\n        <.carousel_next />\n      </.carousel>\n  \"\"\"\n\n  use Phoenix.Component\n\n  attr :id, :string, required: true\n  # APG: since aria-roledescription is \"carousel\", the name must NOT contain\n  # the word \"carousel\".\n  attr :\"aria-label\", :string, default: nil\n  attr :\"aria-labelledby\", :string, default: nil\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block, required: true\n\n  def carousel(assigns) do\n    ~H\"\"\"\n    <section\n      id={@id}\n      data-slot=\"carousel\"\n      data-carousel\n      role=\"group\"\n      aria-roledescription=\"carousel\"\n      aria-label={assigns[:\"aria-label\"]}\n      aria-labelledby={assigns[:\"aria-labelledby\"]}\n      class={[\"group/carousel relative\", @class]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </section>\n    <script>{Phoenix.HTML.raw(~s\"\"\"\n      (function(el){\n        var content = el.querySelector('[data-slot=\"carousel-content\"]');\n        if (content){\n          if (!content.id) content.id = el.id + '-content';\n          var items = content.querySelectorAll('[data-slot=\"carousel-item\"]');\n          var total = items.length;\n          items.forEach(function(it, i){\n            if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);\n          });\n          el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){\n            b.setAttribute('aria-controls', content.id);\n          });\n          var prev = el.querySelector('[data-carousel-prev]');\n          if (prev) prev.disabled = content.scrollLeft <= 0;\n        }\n        el.setAttribute('data-carousel-ready','true');\n      })(document.currentScript.previousElementSibling);\n    \"\"\")}</script>\n    \"\"\"\n  end\n\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block, required: true\n\n  def carousel_content(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"carousel-content\"\n      aria-atomic=\"false\"\n      aria-live=\"polite\"\n      tabindex=\"0\"\n      class={[\n        # motion-safe:scroll-smooth → smooth pan only when prefers-reduced-motion is not reduce\n        # (panning a full-width region is a vestibular trigger; MDN: prefers-reduced-motion).\n        # overscroll-x-contain → swiping past the first/last slide doesn't chain-scroll the page\n        # or trigger browser back-swipe / pull-to-refresh (MDN: overscroll-behavior).\n        \"flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain motion-safe:scroll-smooth scrollbar-none\",\n        \"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-lg\",\n        @class\n      ]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  attr :\"aria-label\", :string, default: nil\n  attr :\"aria-labelledby\", :string, default: nil\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block, required: true\n\n  def carousel_item(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"carousel-item\"\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={assigns[:\"aria-label\"]}\n      aria-labelledby={assigns[:\"aria-labelledby\"]}\n      class={[\"min-w-0 shrink-0 grow-0 basis-full snap-center\", @class]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  attr :\"aria-label\", :string, default: \"Previous slide\"\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block\n\n  def carousel_previous(assigns) do\n    ~H\"\"\"\n    <button\n      type=\"button\"\n      data-slot=\"carousel-previous\"\n      data-carousel-prev\n      aria-label={assigns[:\"aria-label\"]}\n      class={[\n        \"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none\",\n        \"hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50\",\n        \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        \"disabled:pointer-events-none disabled:opacity-40\",\n        \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        \"absolute top-1/2 -left-3 -translate-y-1/2\",\n        @class\n      ]}\n      {@rest}\n    >\n      <%= if @inner_block != [] do %>\n        {render_slot(@inner_block)}\n      <% else %>\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m15 18-6-6 6-6\" /></svg>\n      <% end %>\n    </button>\n    \"\"\"\n  end\n\n  attr :\"aria-label\", :string, default: \"Next slide\"\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block\n\n  def carousel_next(assigns) do\n    ~H\"\"\"\n    <button\n      type=\"button\"\n      data-slot=\"carousel-next\"\n      data-carousel-next\n      aria-label={assigns[:\"aria-label\"]}\n      class={[\n        \"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none\",\n        \"hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50\",\n        \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        \"disabled:pointer-events-none disabled:opacity-40\",\n        \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        \"absolute top-1/2 -right-3 -translate-y-1/2\",\n        @class\n      ]}\n      {@rest}\n    >\n      <%= if @inner_block != [] do %>\n        {render_slot(@inner_block)}\n      <% else %>\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m9 18 6-6-6-6\" /></svg>\n      <% end %>\n    </button>\n    \"\"\"\n  end\nend\n"
    },
    {
      "path": "registry/html/carousel.html",
      "type": "registry:file",
      "target": "snippets/carousel.html",
      "content": "<!--\n  shadcn-htmx — raw HTML carousel snippet.\n\n  Mirrors registry/ui/carousel.tsx. A native CSS scroll-snap slideshow:\n    - Container: role=\"group\" aria-roledescription=\"carousel\" + an accessible\n      name via aria-label (must NOT contain the word \"carousel\", per APG).\n    - Scroller: snap-x snap-mandatory motion-safe:scroll-smooth overflow-x-auto\n      overscroll-x-contain, tabindex=0, aria-live=\"polite\" (a non-auto-rotating\n      carousel announces slide changes). The smooth pan is gated on\n      prefers-reduced-motion (panning a full-width region is a vestibular trigger;\n      MDN: prefers-reduced-motion); overscroll-x-contain stops a swipe past the\n      ends from chain-scrolling the page (MDN: overscroll-behavior).\n    - Each slide: role=\"group\" aria-roledescription=\"slide\" snap-center, with an\n      \"N of M\" accessible name (filled in by the boot script below).\n    - Prev / Next: real <button> elements (Space/Enter + disabled come free).\n\n  The inline <script> right after the wrapper fills in each slide's \"N of M\"\n  label + the initial disabled state. The live scrollBy() + disabled contract\n  lives in public/site.js (keyed on data-slot=\"carousel\"); a minimal version is\n  inlined below so this snippet works standalone.\n\n  Required CSS theme variables: --background, --foreground, --accent,\n  --accent-foreground, --ring, --input, --border. See app/styles/input.css.\n  The .scrollbar-none helper (hides the scrollbar) is defined there too.\n-->\n\n<section id=\"gallery\"\n         data-slot=\"carousel\"\n         data-carousel\n         role=\"group\"\n         aria-roledescription=\"carousel\"\n         aria-label=\"Featured photos\"\n         class=\"group/carousel relative\">\n\n  <div data-slot=\"carousel-content\"\n       aria-atomic=\"false\"\n       aria-live=\"polite\"\n       tabindex=\"0\"\n       class=\"flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain motion-safe:scroll-smooth scrollbar-none focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-lg\">\n\n    <div data-slot=\"carousel-item\" role=\"group\" aria-roledescription=\"slide\"\n         class=\"min-w-0 shrink-0 grow-0 basis-full snap-center\">\n      <div class=\"flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold\">1</div>\n    </div>\n\n    <div data-slot=\"carousel-item\" role=\"group\" aria-roledescription=\"slide\"\n         class=\"min-w-0 shrink-0 grow-0 basis-full snap-center\">\n      <div class=\"flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold\">2</div>\n    </div>\n\n    <div data-slot=\"carousel-item\" role=\"group\" aria-roledescription=\"slide\"\n         class=\"min-w-0 shrink-0 grow-0 basis-full snap-center\">\n      <div class=\"flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold\">3</div>\n    </div>\n\n  </div>\n\n  <button type=\"button\" data-slot=\"carousel-previous\" data-carousel-prev\n          aria-label=\"Previous slide\"\n          class=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 absolute top-1/2 -left-3 -translate-y-1/2\">\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m15 18-6-6 6-6\"/></svg>\n  </button>\n\n  <button type=\"button\" data-slot=\"carousel-next\" data-carousel-next\n          aria-label=\"Next slide\"\n          class=\"inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground shadow-xs transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 absolute top-1/2 -right-3 -translate-y-1/2\">\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m9 18 6-6-6-6\"/></svg>\n  </button>\n\n</section>\n\n<script>\n  // Boot: fill each slide's \"N of M\" label + wire aria-controls + initial state.\n  (function (el) {\n    var content = el.querySelector('[data-slot=\"carousel-content\"]')\n    if (content) {\n      if (!content.id) content.id = el.id + '-content'\n      var items = content.querySelectorAll('[data-slot=\"carousel-item\"]')\n      var total = items.length\n      items.forEach(function (it, i) {\n        if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i + 1) + ' of ' + total)\n      })\n      el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function (b) {\n        b.setAttribute('aria-controls', content.id)\n      })\n      var prev = el.querySelector('[data-carousel-prev]')\n      if (prev) prev.disabled = content.scrollLeft <= 0\n    }\n    el.setAttribute('data-carousel-ready', 'true')\n  })(document.currentScript.previousElementSibling)\n\n  // Minimal standalone version of the public/site.js contract: Prev/Next scroll\n  // by one slide width; the buttons disable at the start / end of the track.\n  document.querySelectorAll('[data-slot=\"carousel\"]').forEach(function (root) {\n    var content = root.querySelector('[data-slot=\"carousel-content\"]')\n    if (!content) return\n    // Honor prefers-reduced-motion: 'smooth' animates the pan, 'auto' jumps\n    // instantly. Panning a full-width region is a vestibular-motion trigger.\n    // MDN: prefers-reduced-motion; Element.scrollBy behavior: smooth vs auto.\n    var scrollBehavior = function () {\n      return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'\n    }\n    var slideWidth = function () {\n      var item = content.querySelector('[data-slot=\"carousel-item\"]')\n      return item ? item.getBoundingClientRect().width + 16 /* gap-4 */ : content.clientWidth\n    }\n    var sync = function () {\n      var prev = root.querySelector('[data-carousel-prev]')\n      var next = root.querySelector('[data-carousel-next]')\n      var max = content.scrollWidth - content.clientWidth\n      if (prev) prev.disabled = content.scrollLeft <= 1\n      if (next) next.disabled = content.scrollLeft >= max - 1\n    }\n    root.querySelectorAll('[data-carousel-prev]').forEach(function (b) {\n      b.addEventListener('click', function () { content.scrollBy({ left: -slideWidth(), behavior: scrollBehavior() }) })\n    })\n    root.querySelectorAll('[data-carousel-next]').forEach(function (b) {\n      b.addEventListener('click', function () { content.scrollBy({ left: slideWidth(), behavior: scrollBehavior() }) })\n    })\n    content.addEventListener('scroll', function () { window.requestAnimationFrame(sync) }, { passive: true })\n    sync()\n  })\n</script>\n"
    }
  ]
}
