shshadcn-htmx

Components

Carousel

A slideshow built on native CSS scroll-snap. Touch, trackpad, and keyboard scrolling are the platform's; Prev/Next buttons advance one slide via scrollBy(). WAI-ARIA carousel roles throughout.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/carousel.json

2. Use it

components/ui/carousel.tsx
import {
  Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext,
} from "@/components/ui/carousel"

<Carousel id="gallery" ariaLabel="Featured photos">
  <CarouselContent>
    <CarouselItem><img src="/1.jpg" alt="…" /></CarouselItem>
    <CarouselItem><img src="/2.jpg" alt="…" /></CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
Or copy the source manually
components/ui/carousel.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Carousel — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui ships a Carousel built on the Embla JS engine. We don't copy that
// React code; we mirror its anatomy (Carousel / CarouselContent / CarouselItem
// / CarouselPrevious / CarouselNext) and translate it to a NATIVE, no-engine
// implementation. Source of truth for the API shape:
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/carousel.tsx (anatomy only)
//
// Accessibility contract follows the WAI-ARIA APG carousel pattern (Basic
// carousel: previous/next controls, no auto-rotation):
//   repos/aria-practices/content/patterns/carousel/carousel-pattern.html
//   repos/aria-practices/content/patterns/carousel/examples/carousel-1-prev-next.html
//
// How this differs from Embla / shadcn:
//   - The scroller is a plain horizontally-overflowing element using CSS
//     scroll-snap (`snap-x snap-mandatory`, each slide `snap-center`). Scrolling
//     itself is therefore native: mouse wheel, trackpad, touch swipe, and the
//     browser's own keyboard scrolling all work with zero JS, and the snap
//     points keep slides aligned. Tailwind v4 ships these utilities natively
//     (repos/tailwindcss/.../src/utilities.ts: snap-x, snap-mandatory,
//     snap-center, scroll-smooth, overscroll-x-contain; motion-safe variant in
//     src/variants.ts).
//   - The only thing the platform does NOT give us is "advance by exactly one
//     slide when the Prev/Next buttons are pressed" + the buttons' disabled
//     state at the ends. public/site.js (keyed on data-slot="carousel") owns
//     that: it calls Element.scrollBy() with the slide width
//     (repos/mdn/files/en-us/web/api/element/scrollby/index.md).
//
// APG roles/states/properties (Basic carousel):
//   - Root container: role="group" + aria-roledescription="carousel" + an
//     accessible name via aria-label / aria-labelledby. Because the
//     roledescription is "carousel", the label must NOT contain the word
//     "carousel". (carousel-pattern.html, "Basic carousel elements")
//   - Each slide: role="group" + aria-roledescription="slide" + an accessible
//     name. When slides have no unique name, "N of M" is the sanctioned
//     fallback because group elements don't support aria-setsize/aria-posinset.
//   - The element wrapping the slides has aria-atomic="false" + aria-live.
//     For a non-auto-rotating carousel APG specifies aria-live="polite".
//   - Prev/Next are real <button>s (recommended) with aria-controls pointing
//     at the slides wrapper.
//
// Composition (matches shadcn's API):
//   <Carousel id="gallery" ariaLabel="Featured photos">
//     <CarouselContent>
//       <CarouselItem><img …/></CarouselItem>
//       <CarouselItem><img …/></CarouselItem>
//     </CarouselContent>
//     <CarouselPrevious />
//     <CarouselNext />
//   </Carousel>

const containerBase = "group/carousel relative"

// The scroller. `snap-x snap-mandatory` turns the row into a snap container;
// `motion-safe:scroll-smooth` makes scrollBy() animate, but only when the user
// has NOT requested reduced motion — panning a full-width region is a
// vestibular-motion trigger, so under prefers-reduced-motion:reduce we drop the
// smooth animation (motion-safe = @media (prefers-reduced-motion: no-preference);
// repos/mdn/files/en-us/web/css/reference/at-rules/@media/prefers-reduced-motion).
// `overflow-x-auto` lets native touch/wheel scrolling work; `overscroll-x-contain`
// stops a swipe past the first/last slide from chain-scrolling the page or
// triggering browser back-swipe / pull-to-refresh
// (repos/mdn/files/en-us/web/css/reference/properties/overscroll-behavior).
// `scrollbar-none` is the Tailwind v4 utility for scrollbar-width:none
// (Firefox/standards); app/styles/input.css adds the WebKit ::-webkit-scrollbar
// supplement so the bar is hidden in Chrome/Safari too. The region stays fully
// scrollable + keyboard-reachable.
const contentBase =
  "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"

// Each slide centres on the snap line and never shrinks below its basis.
const itemBase = "min-w-0 shrink-0 grow-0 basis-full snap-center"

// Prev/Next share the outline icon-button look so the pair reads as a unit.
const navBase =
  "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"

type CarouselProps = PropsWithChildren<{
  // Required so site.js can scope its handlers per carousel and so the
  // Prev/Next buttons can aria-control the slides wrapper by id.
  id: string
  // Accessible name. APG: since aria-roledescription is "carousel", the name
  // must NOT include the word "carousel".
  ariaLabel?: string
  ariaLabelledby?: string
  class?: ClassValue
  // htmx + arbitrary attributes ride onto the root.
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function Carousel(props: CarouselProps) {
  const { id, ariaLabel, ariaLabelledby, class: className, children, ...rest } = props as any
  // Boot script: runs synchronously after the element is parsed, before paint.
  //   - Give every slide its "N of M" aria-label (the APG fallback name) and
  //     wire aria-controls on the Prev/Next buttons to the scroller id.
  //   - Set the Prev button disabled at the start (we open on slide 1).
  // The live scroll/disabled contract lives in public/site.js; this just makes
  // the initial render correct and a11y-complete with no flicker.
  const boot = `(function(el){
    var content = el.querySelector('[data-slot="carousel-content"]');
    if (content){
      if (!content.id) content.id = el.id + '-content';
      var items = content.querySelectorAll('[data-slot="carousel-item"]');
      var total = items.length;
      items.forEach(function(it, i){
        if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);
      });
      el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){
        b.setAttribute('aria-controls', content.id);
      });
      var prev = el.querySelector('[data-carousel-prev]');
      if (prev) prev.disabled = content.scrollLeft <= 0;
    }
    el.setAttribute('data-carousel-ready','true');
  })(document.currentScript.previousElementSibling);`
  return (
    <>
      <section
        id={id}
        data-slot="carousel"
        data-carousel
        role="group"
        aria-roledescription="carousel"
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        class={cn(containerBase, className)}
        {...rest}
      >
        {children}
      </section>
      <script
        // biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
        dangerouslySetInnerHTML={{ __html: boot }}
      />
    </>
  )
}

type CarouselContentProps = PropsWithChildren<{
  class?: ClassValue
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function CarouselContent(props: CarouselContentProps) {
  const { class: className, children, ...rest } = props as any
  // The slides wrapper. APG: aria-atomic="false" + aria-live="polite" for a
  // carousel that does NOT auto-rotate, so changing slides is announced.
  // tabindex="0" makes the scroll region keyboard-focusable so the browser's
  // built-in arrow/Page scrolling reaches it (a scrollable region needs a tab
  // stop to be operable by keyboard-only users).
  return (
    <div
      data-slot="carousel-content"
      aria-atomic="false"
      aria-live="polite"
      tabindex={0}
      class={cn(contentBase, className)}
      {...rest}
    >
      {children}
    </div>
  )
}

type CarouselItemProps = PropsWithChildren<{
  class?: ClassValue
  // Optional explicit accessible name for the slide. If omitted, the boot
  // script assigns the APG "N of M" fallback.
  ariaLabel?: string
  ariaLabelledby?: string
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function CarouselItem(props: CarouselItemProps) {
  const { class: className, ariaLabel, ariaLabelledby, children, ...rest } = props as any
  // Each slide is role="group" + aria-roledescription="slide". The label is
  // either provided here or filled in as "N of M" by the boot script.
  return (
    <div
      data-slot="carousel-item"
      role="group"
      aria-roledescription="slide"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      class={cn(itemBase, className)}
      {...rest}
    >
      {children}
    </div>
  )
}

type CarouselNavProps = PropsWithChildren<{
  class?: ClassValue
  ariaLabel?: string
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

// Default chevron icons (inline SVG so the snippet has no icon-lib dependency).
function ChevronLeft() {
  return (
    <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>
  )
}
function ChevronRight() {
  return (
    <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>
  )
}

export function CarouselPrevious(props: CarouselNavProps) {
  const { class: className, ariaLabel = "Previous slide", children, ...rest } = props as any
  // Real <button>: role + Space/Enter activation + disabled come from the
  // platform. Activating it does NOT move focus (APG note), so users can
  // repeatedly click/Enter to keep advancing.
  return (
    <button
      type="button"
      data-slot="carousel-previous"
      data-carousel-prev
      aria-label={ariaLabel}
      class={cn(navBase, "absolute top-1/2 -left-3 -translate-y-1/2", className)}
      {...rest}
    >
      {children ?? <ChevronLeft />}
    </button>
  )
}

export function CarouselNext(props: CarouselNavProps) {
  const { class: className, ariaLabel = "Next slide", children, ...rest } = props as any
  return (
    <button
      type="button"
      data-slot="carousel-next"
      data-carousel-next
      aria-label={ariaLabel}
      class={cn(navBase, "absolute top-1/2 -right-3 -translate-y-1/2", className)}
      {...rest}
    >
      {children ?? <ChevronRight />}
    </button>
  )
}

1. Save the file

Copy carousel.html into templates/components/.

2. Use it

templates/components/carousel.html
{% from "components/carousel.html" import carousel, carousel_content_open, carousel_content_close, carousel_item, carousel_previous, carousel_next %}

{% call carousel(id="gallery", aria_label="Featured photos") %}
  {{ carousel_content_open() }}
    {% call(_) carousel_item() %}<img src="/1.jpg" alt="…">{% endcall %}
    {% call(_) carousel_item() %}<img src="/2.jpg" alt="…">{% endcall %}
  {{ carousel_content_close() }}
  {{ carousel_previous() }}
  {{ carousel_next() }}
{% endcall %}
View source
templates/components/carousel.html
{# Carousel macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/carousel.tsx. Native CSS scroll-snap slideshow with a
   role="group" aria-roledescription="carousel" container, role="group"
   aria-roledescription="slide" items, and real <button> prev/next controls.
   The boot <script> right after the wrapper fills in each slide's "N of M"
   label and the initial disabled state; public/site.js owns the live
   scrollBy() + disabled contract (keyed on data-slot="carousel").

   Usage:
     {% from "components/carousel.html" import carousel, carousel_content_open, carousel_content_close, carousel_item, carousel_previous, carousel_next %}

     {% call carousel(id="gallery", aria_label="Featured photos") %}
       {{ carousel_content_open() }}
         {% call(_) carousel_item() %}<img src="…" alt="…">{% endcall %}
         {% call(_) carousel_item() %}<img src="…" alt="…">{% endcall %}
       {{ carousel_content_close() }}
       {{ carousel_previous() }}
       {{ carousel_next() }}
     {% endcall %} #}

{% macro carousel(id, aria_label=none, aria_labelledby=none, extra_class="", attrs={}) %}
<section id="{{ id }}"
         data-slot="carousel"
         data-carousel
         role="group"
         aria-roledescription="carousel"
         {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
         {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
         class="group/carousel relative {{ extra_class }}"
         {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}>
  {{ caller() }}
</section>
<script>(function(el){
  var content = el.querySelector('[data-slot="carousel-content"]');
  if (content){
    if (!content.id) content.id = el.id + '-content';
    var items = content.querySelectorAll('[data-slot="carousel-item"]');
    var total = items.length;
    items.forEach(function(it, i){
      if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);
    });
    el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){
      b.setAttribute('aria-controls', content.id);
    });
    var prev = el.querySelector('[data-carousel-prev]');
    if (prev) prev.disabled = content.scrollLeft <= 0;
  }
  el.setAttribute('data-carousel-ready','true');
})(document.currentScript.previousElementSibling);</script>
{% endmacro %}

{% macro carousel_content_open(extra_class="") -%}
<div data-slot="carousel-content"
     aria-atomic="false"
     aria-live="polite"
     tabindex="0"
     {# 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. #}
     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 }}">
{%- endmacro %}
{% macro carousel_content_close() %}</div>{% endmacro %}

{% macro carousel_item(aria_label=none, aria_labelledby=none, extra_class="") %}
<div data-slot="carousel-item"
     role="group"
     aria-roledescription="slide"
     {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
     {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
     class="min-w-0 shrink-0 grow-0 basis-full snap-center {{ extra_class }}">
  {{ caller() }}
</div>
{% endmacro %}

{% macro carousel_previous(aria_label="Previous slide", extra_class="") %}
<button type="button"
        data-slot="carousel-previous"
        data-carousel-prev
        aria-label="{{ aria_label }}"
        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>
{% endmacro %}

{% macro carousel_next(aria_label="Next slide", extra_class="") %}
<button type="button"
        data-slot="carousel-next"
        data-carousel-next
        aria-label="{{ aria_label }}"
        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>
{% endmacro %}

1. Save the file

Add carousel.tmpl alongside your other templates.

2. Use it

components/carousel.tmpl
{{template "carousel" (dict
  "ID" "gallery" "AriaLabel" "Featured photos"
  "Body" (htmlSafe `
    {{template "carousel_content" (dict "Body" (htmlSafe `
      {{template "carousel_item" (dict "Body" (htmlSafe "<img src=\"/1.jpg\" alt=\"…\">"))}}
      {{template "carousel_item" (dict "Body" (htmlSafe "<img src=\"/2.jpg\" alt=\"…\">"))}}
    `))}}
    {{template "carousel_previous" (dict)}}
    {{template "carousel_next" (dict)}}
  `))}}
View source
components/carousel.tmpl
{{/*
  Carousel template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/carousel.tsx. Native CSS scroll-snap slideshow.
  Provides named templates:
    - "carousel"           — the role="group" carousel wrapper + boot script
    - "carousel_content"   — the scroll-snap slides wrapper (compose items in .Body)
    - "carousel_item"      — one role="group" aria-roledescription="slide"
    - "carousel_previous"  — the prev <button>
    - "carousel_next"      — the next <button>

  Usage (compose the inner HTML so you can build the slide list):

      {{template "carousel" (dict "ID" "gallery" "AriaLabel" "Featured photos"
        "Body" (htmlSafe (printf `%s%s%s`
          (... carousel_content with items ...)
          (... carousel_previous ...)
          (... carousel_next ...))))}}

  The boot <script> fills each slide's "N of M" label + initial disabled state;
  public/site.js owns the live scrollBy() + disabled contract.
*/}}

{{define "carousel"}}
<section id="{{.ID}}"
         data-slot="carousel"
         data-carousel
         role="group"
         aria-roledescription="carousel"
         {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
         {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
         class="group/carousel relative">
  {{.Body}}
</section>
<script>(function(el){
  var content = el.querySelector('[data-slot="carousel-content"]');
  if (content){
    if (!content.id) content.id = el.id + '-content';
    var items = content.querySelectorAll('[data-slot="carousel-item"]');
    var total = items.length;
    items.forEach(function(it, i){
      if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);
    });
    el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){
      b.setAttribute('aria-controls', content.id);
    });
    var prev = el.querySelector('[data-carousel-prev]');
    if (prev) prev.disabled = content.scrollLeft <= 0;
  }
  el.setAttribute('data-carousel-ready','true');
})(document.currentScript.previousElementSibling);</script>
{{end}}

{{define "carousel_content"}}
<div data-slot="carousel-content"
     aria-atomic="false"
     aria-live="polite"
     tabindex="0"
     {{/* 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. */}}
     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>
{{end}}

{{define "carousel_item"}}
<div data-slot="carousel-item"
     role="group"
     aria-roledescription="slide"
     {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
     {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
     class="min-w-0 shrink-0 grow-0 basis-full snap-center">{{.Body}}</div>
{{end}}

{{define "carousel_previous"}}
{{- $label := or .AriaLabel "Previous slide" -}}
<button type="button"
        data-slot="carousel-previous"
        data-carousel-prev
        aria-label="{{$label}}"
        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>
{{end}}

{{define "carousel_next"}}
{{- $label := or .AriaLabel "Next slide" -}}
<button type="button"
        data-slot="carousel-next"
        data-carousel-next
        aria-label="{{$label}}"
        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>
{{end}}

1. Save the file

Drop carousel.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/carousel.ex
<.carousel id="gallery" aria-label="Featured photos">
  <.carousel_content>
    <.carousel_item><img src="/1.jpg" alt="…" /></.carousel_item>
    <.carousel_item><img src="/2.jpg" alt="…" /></.carousel_item>
  </.carousel_content>
  <.carousel_previous />
  <.carousel_next />
</.carousel>
View source
lib/my_app_web/components/carousel.ex
defmodule ShadcnHtmx.Components.Carousel do
  @moduledoc """
  Carousel — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/carousel.tsx. A native CSS scroll-snap slideshow:
  five function components — `carousel`, `carousel_content`, `carousel_item`,
  `carousel_previous`, `carousel_next`. The scroll itself is native
  (snap-x / snap-mandatory / snap-center); public/site.js owns the live
  scrollBy() + Prev/Next disabled contract (keyed on data-slot="carousel").

  Accessibility follows the WAI-ARIA APG Carousel pattern (Basic carousel):
  role="group" + aria-roledescription="carousel" on the container, role="group"
  + aria-roledescription="slide" on each item, real <button> prev/next.

  ## Examples

      <.carousel id="gallery" aria-label="Featured photos">
        <.carousel_content>
          <.carousel_item><img src="…" alt="…" /></.carousel_item>
          <.carousel_item><img src="…" alt="…" /></.carousel_item>
        </.carousel_content>
        <.carousel_previous />
        <.carousel_next />
      </.carousel>
  """

  use Phoenix.Component

  attr :id, :string, required: true
  # APG: since aria-roledescription is "carousel", the name must NOT contain
  # the word "carousel".
  attr :"aria-label", :string, default: nil
  attr :"aria-labelledby", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def carousel(assigns) do
    ~H"""
    <section
      id={@id}
      data-slot="carousel"
      data-carousel
      role="group"
      aria-roledescription="carousel"
      aria-label={assigns[:"aria-label"]}
      aria-labelledby={assigns[:"aria-labelledby"]}
      class={["group/carousel relative", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </section>
    <script>{Phoenix.HTML.raw(~s"""
      (function(el){
        var content = el.querySelector('[data-slot="carousel-content"]');
        if (content){
          if (!content.id) content.id = el.id + '-content';
          var items = content.querySelectorAll('[data-slot="carousel-item"]');
          var total = items.length;
          items.forEach(function(it, i){
            if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);
          });
          el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){
            b.setAttribute('aria-controls', content.id);
          });
          var prev = el.querySelector('[data-carousel-prev]');
          if (prev) prev.disabled = content.scrollLeft <= 0;
        }
        el.setAttribute('data-carousel-ready','true');
      })(document.currentScript.previousElementSibling);
    """)}</script>
    """
  end

  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def carousel_content(assigns) do
    ~H"""
    <div
      data-slot="carousel-content"
      aria-atomic="false"
      aria-live="polite"
      tabindex="0"
      class={[
        # motion-safe:scroll-smooth → smooth pan only when prefers-reduced-motion is not reduce
        # (panning a full-width region is a vestibular trigger; MDN: prefers-reduced-motion).
        # overscroll-x-contain → swiping past the first/last slide doesn't chain-scroll the page
        # or trigger browser back-swipe / pull-to-refresh (MDN: overscroll-behavior).
        "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",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  attr :"aria-label", :string, default: nil
  attr :"aria-labelledby", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def carousel_item(assigns) do
    ~H"""
    <div
      data-slot="carousel-item"
      role="group"
      aria-roledescription="slide"
      aria-label={assigns[:"aria-label"]}
      aria-labelledby={assigns[:"aria-labelledby"]}
      class={["min-w-0 shrink-0 grow-0 basis-full snap-center", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  attr :"aria-label", :string, default: "Previous slide"
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block

  def carousel_previous(assigns) do
    ~H"""
    <button
      type="button"
      data-slot="carousel-previous"
      data-carousel-prev
      aria-label={assigns[:"aria-label"]}
      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",
        @class
      ]}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        <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>
      <% end %>
    </button>
    """
  end

  attr :"aria-label", :string, default: "Next slide"
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block

  def carousel_next(assigns) do
    ~H"""
    <button
      type="button"
      data-slot="carousel-next"
      data-carousel-next
      aria-label={assigns[:"aria-label"]}
      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",
        @class
      ]}
      {@rest}
    >
      <%= if @inner_block != [] do %>
        {render_slot(@inner_block)}
      <% else %>
        <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>
      <% end %>
    </button>
    """
  end
end

1. Save the file

Paste the markup; it relies only on the theme tokens in styles.css and inlines the scroll wiring.

2. Use it

snippets/carousel.html
<section id="gallery" data-slot="carousel" data-carousel role="group"
         aria-roledescription="carousel" aria-label="Featured photos"
         class="group/carousel relative">
  <div data-slot="carousel-content" aria-live="polite" tabindex="0"
       class="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth scrollbar-none …">
    <div data-slot="carousel-item" role="group" aria-roledescription="slide"
         class="min-w-0 shrink-0 basis-full snap-center">…</div>
  </div>
  <button data-carousel-prev aria-label="Previous slide" class="…">‹</button>
  <button data-carousel-next aria-label="Next slide" class="…">›</button>
</section>
<script>/* see snippets/carousel.html for the boot + scroll wiring */</script>
View source
snippets/carousel.html
<!--
  shadcn-htmx — raw HTML carousel snippet.

  Mirrors registry/ui/carousel.tsx. A native CSS scroll-snap slideshow:
    - Container: role="group" aria-roledescription="carousel" + an accessible
      name via aria-label (must NOT contain the word "carousel", per APG).
    - Scroller: snap-x snap-mandatory motion-safe:scroll-smooth overflow-x-auto
      overscroll-x-contain, tabindex=0, aria-live="polite" (a non-auto-rotating
      carousel announces slide changes). The smooth pan is gated on
      prefers-reduced-motion (panning a full-width region is a vestibular trigger;
      MDN: prefers-reduced-motion); overscroll-x-contain stops a swipe past the
      ends from chain-scrolling the page (MDN: overscroll-behavior).
    - Each slide: role="group" aria-roledescription="slide" snap-center, with an
      "N of M" accessible name (filled in by the boot script below).
    - Prev / Next: real <button> elements (Space/Enter + disabled come free).

  The inline <script> right after the wrapper fills in each slide's "N of M"
  label + the initial disabled state. The live scrollBy() + disabled contract
  lives in public/site.js (keyed on data-slot="carousel"); a minimal version is
  inlined below so this snippet works standalone.

  Required CSS theme variables: --background, --foreground, --accent,
  --accent-foreground, --ring, --input, --border. See app/styles/input.css.
  The .scrollbar-none helper (hides the scrollbar) is defined there too.
-->

<section id="gallery"
         data-slot="carousel"
         data-carousel
         role="group"
         aria-roledescription="carousel"
         aria-label="Featured photos"
         class="group/carousel relative">

  <div data-slot="carousel-content"
       aria-atomic="false"
       aria-live="polite"
       tabindex="0"
       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">

    <div data-slot="carousel-item" role="group" aria-roledescription="slide"
         class="min-w-0 shrink-0 grow-0 basis-full snap-center">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold">1</div>
    </div>

    <div data-slot="carousel-item" role="group" aria-roledescription="slide"
         class="min-w-0 shrink-0 grow-0 basis-full snap-center">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold">2</div>
    </div>

    <div data-slot="carousel-item" role="group" aria-roledescription="slide"
         class="min-w-0 shrink-0 grow-0 basis-full snap-center">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold">3</div>
    </div>

  </div>

  <button type="button" data-slot="carousel-previous" data-carousel-prev
          aria-label="Previous slide"
          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>

  <button type="button" data-slot="carousel-next" data-carousel-next
          aria-label="Next slide"
          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>

</section>

<script>
  // Boot: fill each slide's "N of M" label + wire aria-controls + initial state.
  (function (el) {
    var content = el.querySelector('[data-slot="carousel-content"]')
    if (content) {
      if (!content.id) content.id = el.id + '-content'
      var items = content.querySelectorAll('[data-slot="carousel-item"]')
      var total = items.length
      items.forEach(function (it, i) {
        if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i + 1) + ' of ' + total)
      })
      el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function (b) {
        b.setAttribute('aria-controls', content.id)
      })
      var prev = el.querySelector('[data-carousel-prev]')
      if (prev) prev.disabled = content.scrollLeft <= 0
    }
    el.setAttribute('data-carousel-ready', 'true')
  })(document.currentScript.previousElementSibling)

  // Minimal standalone version of the public/site.js contract: Prev/Next scroll
  // by one slide width; the buttons disable at the start / end of the track.
  document.querySelectorAll('[data-slot="carousel"]').forEach(function (root) {
    var content = root.querySelector('[data-slot="carousel-content"]')
    if (!content) return
    // Honor prefers-reduced-motion: 'smooth' animates the pan, 'auto' jumps
    // instantly. Panning a full-width region is a vestibular-motion trigger.
    // MDN: prefers-reduced-motion; Element.scrollBy behavior: smooth vs auto.
    var scrollBehavior = function () {
      return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
    }
    var slideWidth = function () {
      var item = content.querySelector('[data-slot="carousel-item"]')
      return item ? item.getBoundingClientRect().width + 16 /* gap-4 */ : content.clientWidth
    }
    var sync = function () {
      var prev = root.querySelector('[data-carousel-prev]')
      var next = root.querySelector('[data-carousel-next]')
      var max = content.scrollWidth - content.clientWidth
      if (prev) prev.disabled = content.scrollLeft <= 1
      if (next) next.disabled = content.scrollLeft >= max - 1
    }
    root.querySelectorAll('[data-carousel-prev]').forEach(function (b) {
      b.addEventListener('click', function () { content.scrollBy({ left: -slideWidth(), behavior: scrollBehavior() }) })
    })
    root.querySelectorAll('[data-carousel-next]').forEach(function (b) {
      b.addEventListener('click', function () { content.scrollBy({ left: slideWidth(), behavior: scrollBehavior() }) })
    })
    content.addEventListener('scroll', function () { window.requestAnimationFrame(sync) }, { passive: true })
    sync()
  })
</script>

Examples

Basic — one slide at a time

Swipe / scroll horizontally, or use the Prev/Next buttons. The buttons disable at each end of the track. Slides snap to centre.

The scroller is a plain overflow region with snap-x snap-mandatory and each slide snap-center, so touch, trackpad and the browser's own keyboard scrolling all work with zero JS. The container is role="group" with aria-roledescription="carousel"; each slide is a labelled role="group" announced as "N of M". The Prev/Next buttons are the only scripted part — they call scrollBy().

<Carousel id="gallery" ariaLabel="Demo photos">
  <CarouselContent>
    <CarouselItem><Tile n={1} /></CarouselItem>
    <CarouselItem><Tile n={2} /></CarouselItem>
    <CarouselItem><Tile n={3} /></CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
{% call carousel(id="gallery", aria_label="Demo photos") %}
  {{ carousel_content_open() }}
    {% call(_) carousel_item() %}…slide 1…{% endcall %}
    {% call(_) carousel_item() %}…slide 2…{% endcall %}
    {% call(_) carousel_item() %}…slide 3…{% endcall %}
  {{ carousel_content_close() }}
  {{ carousel_previous() }}
  {{ carousel_next() }}
{% endcall %}
{{template "carousel" (dict "ID" "gallery" "AriaLabel" "Demo photos"
  "Body" (htmlSafe `
    {{template "carousel_content" (dict "Body" (htmlSafe `…slides…`))}}
    {{template "carousel_previous" (dict)}}
    {{template "carousel_next" (dict)}}
  `))}}
<.carousel id="gallery" aria-label="Demo photos">
  <.carousel_content>
    <.carousel_item>…slide 1</.carousel_item>
    <.carousel_item>…slide 2</.carousel_item>
    <.carousel_item>…slide 3</.carousel_item>
  </.carousel_content>
  <.carousel_previous />
  <.carousel_next />
</.carousel>
<section id="ex-basic-carousel" data-slot="carousel" data-carousel="true" role="group" aria-roledescription="carousel" aria-label="Demo photos" class="group/carousel relative w-full max-w-md">
  <div data-slot="carousel-content" aria-atomic="false" aria-live="polite" tabindex="0" 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">
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">1</div>
    </div>
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">2</div>
    </div>
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">3</div>
    </div>
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">4</div>
    </div>
  </div>
  <button type="button" data-slot="carousel-previous" data-carousel-prev="true" aria-label="Previous slide" 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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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">
      </path>
    </svg>
  </button>
  <button type="button" data-slot="carousel-next" data-carousel-next="true" aria-label="Next slide" 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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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">
      </path>
    </svg>
  </button>
</section>
<script>
  (function(el){
    var content = el.querySelector('[data-slot="carousel-content"]');
    if (content){
      if (!content.id) content.id = el.id + '-content';
      var items = content.querySelectorAll('[data-slot="carousel-item"]');
      var total = items.length;
      items.forEach(function(it, i){
        if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);
      });
      el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){
        b.setAttribute('aria-controls', content.id);
      });
      var prev = el.querySelector('[data-carousel-prev]');
      if (prev) prev.disabled = content.scrollLeft <= 0;
    }
    el.setAttribute('data-carousel-ready','true');
  })(document.currentScript.previousElementSibling);
</script>

Multiple slides per view

Override each item's basis so several slides show at once. The snap points still keep them aligned as you scroll.

Carousels aren't only one-at-a-time. Because every slide is a flex child, you control how many are visible by changing the item's basis (e.g. basis-1/2 for two, basis-1/3 for three). Everything else — snapping, the Prev/Next scrollBy step, the ARIA labels — is unchanged.

<Carousel id="thumbs" ariaLabel="Product thumbnails">
  <CarouselContent>
    <CarouselItem class="basis-1/2">…</CarouselItem>
    <CarouselItem class="basis-1/2">…</CarouselItem>
    <CarouselItem class="basis-1/2">…</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
{% call carousel(id="thumbs", aria_label="Product thumbnails") %}
  {{ carousel_content_open() }}
    {% call(_) carousel_item(extra_class="basis-1/2") %}…{% endcall %}
    {% call(_) carousel_item(extra_class="basis-1/2") %}…{% endcall %}
  {{ carousel_content_close() }}
  {{ carousel_previous() }}
  {{ carousel_next() }}
{% endcall %}
{{template "carousel_item" (dict "Body" (htmlSafe `…`))}}
{{/* add class="basis-1/2" by composing your own item div, or extend the template */}}
<.carousel id="thumbs" aria-label="Product thumbnails">
  <.carousel_content>
    <.carousel_item class="basis-1/2"></.carousel_item>
    <.carousel_item class="basis-1/2"></.carousel_item>
  </.carousel_content>
  <.carousel_previous />
  <.carousel_next />
</.carousel>
<section id="ex-multi-carousel" data-slot="carousel" data-carousel="true" role="group" aria-roledescription="carousel" aria-label="Product thumbnails" class="group/carousel relative w-full max-w-md">
  <div data-slot="carousel-content" aria-atomic="false" aria-live="polite" tabindex="0" 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">
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center basis-1/2">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">1</div>
    </div>
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center basis-1/2">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">2</div>
    </div>
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center basis-1/2">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">3</div>
    </div>
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center basis-1/2">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">4</div>
    </div>
    <div data-slot="carousel-item" role="group" aria-roledescription="slide" class="min-w-0 shrink-0 grow-0 basis-full snap-center basis-1/2">
      <div class="flex aspect-video items-center justify-center rounded-lg border bg-muted text-4xl font-semibold text-muted-foreground">5</div>
    </div>
  </div>
  <button type="button" data-slot="carousel-previous" data-carousel-prev="true" aria-label="Previous slide" 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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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">
      </path>
    </svg>
  </button>
  <button type="button" data-slot="carousel-next" data-carousel-next="true" aria-label="Next slide" 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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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">
      </path>
    </svg>
  </button>
</section>
<script>
  (function(el){
    var content = el.querySelector('[data-slot="carousel-content"]');
    if (content){
      if (!content.id) content.id = el.id + '-content';
      var items = content.querySelectorAll('[data-slot="carousel-item"]');
      var total = items.length;
      items.forEach(function(it, i){
        if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', (i+1)+' of '+total);
      });
      el.querySelectorAll('[data-carousel-prev],[data-carousel-next]').forEach(function(b){
        b.setAttribute('aria-controls', content.id);
      });
      var prev = el.querySelector('[data-carousel-prev]');
      if (prev) prev.disabled = content.scrollLeft <= 0;
    }
    el.setAttribute('data-carousel-ready','true');
  })(document.currentScript.previousElementSibling);
</script>

API Reference

<Carousel>

PropTypeDefaultDescription
id*string
Scopes the boot script + site.js handlers to this carousel and links the Prev/Next buttons to the scroller via aria-controls.
ariaLabelstring
Accessible name for the carousel container. APG: since aria-roledescription is "carousel", the name must NOT contain the word "carousel".APGCarousel — name the container
ariaLabelledbystring
Id of a visible element that names the carousel (alternative to ariaLabel).MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required