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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/*
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
<.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
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
<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
<!--
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 [&_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">
</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 [&_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">
</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 [&_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">
</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 [&_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">
</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>Further reading
API Reference
<Carousel>
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Scopes the boot script + site.js handlers to this carousel and links the Prev/Next buttons to the scroller via aria-controls. |
ariaLabel | string | — | Accessible name for the carousel container. APG: since aria-roledescription is "carousel", the name must NOT contain the word "carousel".APGCarousel — name the container |
ariaLabelledby | string | — | Id of a visible element that names the carousel (alternative to ariaLabel).MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required