Rapid Rails UI Pro

Upgrade to unlock this component

Get Pro →

Carousel

A flexible, accessible slide carousel built on CSS scroll-snap and a single Stimulus controller. One component, six variants, fully responsive.

Key Features

  • Six variants:default, :banner, :hero, :cards, :testimonials, :fullscreen
  • CSS scroll-snap — native touch swipe, free trackpad scroll, smooth GPU-accelerated motion
  • IntersectionObserver active tracking — the JS knows which slide is in view regardless of how it got there
  • Auto-rotate with hover, tab-visibility, and reduced-motion pauses
  • Responsive items-per-view — Integer or { base:, sm:, md:, lg:, xl: } hash
  • Loop or boundary-clamped navigation
  • Keyboard navigation — Arrow keys, Home, End
  • Full ARIA carousel patternrole="region", aria-roledescription, live region, indicator aria-current
  • Stimulus public APInext(), prev(), goTo(), pause(), resume()
  • Pro tier — covered by the RRUI Pro license

Installation

Carousel is a Pro component. Once your RRUI Pro license is activated, the helper is available everywhere.

Quickest possible carousel
<%= rui_carousel(id: "my-first-carousel") do |c| %>
  <% c.with_slide(label: "Slide one") do %>
    <img src="https://picsum.photos/id/1015/1200/480" alt="" />
  <% end %>
  <% c.with_slide(label: "Slide two") do %>
    <img src="https://picsum.photos/id/1018/1200/480" alt="" />
  <% end %>
<% end %>

Quick Start

Pass an id, add at least two slides via with_slide, and you're done. Arrows and indicators are auto-suppressed when fewer than two slides are present.

Quick Start
<%= rui_carousel(id: "quickstart-carousel") do |c| %>
  <% c.with_slide(label: "Mountain") do %>
    <img src="https://picsum.photos/id/1015/1200/480"
         alt="Mountain landscape"
         class="w-full h-[320px] object-cover"
         loading="lazy" />
  <% end %>
  <% c.with_slide(label: "Forest") do %>
    <img src="https://picsum.photos/id/1018/1200/480"
         alt="Forest clearing"
         class="w-full h-[320px] object-cover"
         loading="lazy" />
  <% end %>
  <% c.with_slide(label: "Coastline") do %>
    <img src="https://picsum.photos/id/1043/1200/480"
         alt="Coastline aerial"
         class="w-full h-[320px] object-cover"
         loading="lazy" />
  <% end %>
<% end %>

Variants

Six variants tuned for the most common carousel use cases. Each variant just sets a sensible bundle of defaults (items_per_view, show_arrows, arrow_position, indicator_position) — every default is overridable by passing the corresponding kwarg.

Variant Use Case items_per_view Arrows Arrow Position Indicators
:defaultGeneric 1-up1yes:sides:outside
:bannerFull-bleed promo1no:overlay:overlay_bottom_right
:heroMarketing splash1no:overlay:overlay_bottom_right
:cardsMulti-card slider{ base:1, sm:2, md:3 }yes:outside:outside
:testimonialsQuote cards{ base:1, md:3 }yes:outside:outside
:fullscreen100vh slide deck1yes:overlay:overlay_bottom

:default — Generic 1-up

The all-purpose variant. Side arrows, dots below. Reach for this when nothing else fits.

:default
<%= rui_carousel(id: "promo", variant: :default) do |c| %>
  <% c.with_slide(label: "Aerial pasture") do %>
    <img src="..." class="w-full h-[360px] object-cover" loading="lazy" />
  <% end %>
  <% # more slides %>
<% end %>

:banner — Full-bleed promo

Full-width promotional strip. Indicators overlay bottom-right; arrows fade in over the slide when shown. Pair with auto_rotate for a hands-off rotator.

:banner
<%= rui_carousel(id: "promo", variant: :banner, auto_rotate: true, interval: 5000) do |c| %>
  <% c.with_slide(label: "Spring Yarding") do %>
    <div class="relative">
      <img src="..." class="w-full h-[360px] object-cover" loading="lazy" />
      <div class="absolute inset-0 bg-gradient-to-r from-black/60 to-transparent"></div>
      <div class="absolute bottom-8 left-8 text-white max-w-md">
        <p class="text-sm font-medium opacity-80 mb-1">Featured event</p>
        <h3 class="text-2xl font-bold">Spring Yarding 2026</h3>
      </div>
    </div>
  <% end %>
<% end %>

:hero — Marketing splash

Same overlay-control treatment as :banner, but reach for it when the slide is the dominant first-fold marketing surface and you want a taller stage with a call-to-action.

:hero
<%= rui_carousel(id: "hero", variant: :hero) do |c| %>
  <% c.with_slide(label: "Premium genetics") do %>
    <div class="relative h-[600px]">
      <img src="..." class="w-full h-full object-cover" loading="eager" />
      <div class="absolute inset-0 bg-black/40"></div>
      <div class="absolute inset-0 flex items-center justify-center text-center text-white px-8">
        <div>
          <h1 class="text-5xl font-bold">Premium genetics</h1>
          <%= rui_button("Browse", color: :primary, size: :lg, class: "mt-6") %>
        </div>
      </div>
    </div>
  <% end %>
<% end %>

:cards — Multi-card slider

Responsive multi-card layout — 1-up on mobile, 2-up on small, 3-up on medium and beyond. Override items_per_view to dial it in for your design.

:cards
<%= rui_carousel(id: "products", variant: :cards,
                 items_per_view: { base: 1, sm: 2, lg: 4 }) do |c| %>
  <% @products.each do |p| %>
    <% c.with_slide(label: p.name) do %>
      <%= render p %>
    <% end %>
  <% end %>
<% end %>

:testimonials — Quote cards

Three quote cards on md+, single column on mobile. Tuned for short testimonial copy and a single attribution line.

:testimonials
<%= rui_carousel(id: "quotes", variant: :testimonials, color: :emerald) do |c| %>
  <% @testimonials.each do |t| %>
    <% c.with_slide(label: t.author) do %>
      <article class="p-6 bg-white dark:bg-zinc-900 rounded-xl border h-full">
        <p>&ldquo;<%= t.body %>&rdquo;</p>
        <p class="mt-4 font-medium"><%= t.author %></p>
      </article>
    <% end %>
  <% end %>
<% end %>

:fullscreen — 100vh deck

Sets the root to h-screen with overlay controls — great for product tours, splash decks, and onboarding flows. The live demo below uses a capped height for the docs page; in production it fills the viewport.

:fullscreen
<%= rui_carousel(id: "splash", variant: :fullscreen, loop: false) do |c| %>
  <% c.with_slide { render "splash/intro" } %>
  <% c.with_slide { render "splash/features" } %>
  <% c.with_slide { render "splash/cta" } %>
<% end %>

Slots

Carousel has a single slot: with_slide. Each slide takes an optional label: that propagates to the slide's ARIA label and the matching indicator's ARIA label.

The label: parameter

Open the demo below, focus the carousel with the Tab key, and a screen reader will read 'Slide 2 of 3: Forest clearing' when slide two becomes active. Without label: it just announces 'Slide 2 of 3'.

Labelled slides
<%= rui_carousel(id: "labelled") do |c| %>
  <% c.with_slide(label: "Mountain view") do %>
    <img src="..." alt="" />
  <% end %>
  <% c.with_slide(label: "Forest clearing") do %>
    <img src="..." alt="" />
  <% end %>
<% end %>

Arbitrary slide content

Slides are generic wrappers — pass any markup. Images, cards, partials, Turbo Frames, Lookbook previews — all work.

Slide content can be anything
<%= rui_carousel(id: "mixed-content") do |c| %>
  <% c.with_slide do %>
    <%= image_tag "hero.jpg", class: "w-full" %>
  <% end %>
  <% c.with_slide do %>
    <%= render "shared/feature_card", feature: @feature %>
  <% end %>
  <% c.with_slide do %>
    <%= turbo_frame_tag dom_id(@latest_post), src: post_path(@latest_post) %>
  <% end %>
<% end %>

Auto-Rotate

Set auto_rotate: true to advance slides on a setInterval. Combine with interval: (milliseconds), pause_on_hover:, and respect_reduced_motion: to tune the experience.

Off (default)

Auto-rotate every 3s

Auto-rotate every 6s

Auto-rotate
<%# Off (default) %>
<%= rui_carousel(id: "off") do |c| ... end %>

<%# Every 3 seconds %>
<%= rui_carousel(id: "every-3s", auto_rotate: true, interval: 3000) do |c| ... end %>

<%# Every 6 seconds %>
<%= rui_carousel(id: "every-6s", auto_rotate: true, interval: 6000) do |c| ... end %>

Pause on hover

pause_on_hover: true (default) pauses the timer when the cursor enters the region. Hover the demo above to see it freeze.

Disable pause-on-hover
<%= rui_carousel(id: "always-rotating",
                 auto_rotate: true,
                 interval: 4000,
                 pause_on_hover: false) do |c| %>
  <% # slides %>
<% end %>

Respect reduced motion

When the OS advertises prefers-reduced-motion: reduce, auto-rotate is suppressed at boot. Manual navigation also drops from smooth scroll to instant jump. Set respect_reduced_motion: false to override (not recommended).

Reduced-motion override
<%# Default: honour the user's OS setting %>
<%= rui_carousel(id: "respectful", auto_rotate: true) do |c| ... end %>

<%# Force rotation even when the user asked for less motion %>
<%= rui_carousel(id: "ignores-pref",
                 auto_rotate: true,
                 respect_reduced_motion: false) do |c| ... end %>

Loop Behavior

loop: true (default) wraps at boundaries. With loop: false, the prev/next buttons toggle aria-disabled (and the HTML disabled attribute) at each edge.

loop: true

loop: false (arrows disable at edges)

Loop behaviour
<%# Default: wrap at boundaries %>
<%= rui_carousel(id: "wraps", loop: true) do |c| ... end %>

<%# Clamp at boundaries; arrows disable at the ends %>
<%= rui_carousel(id: "clamped", loop: false) do |c| ... end %>

Tip: loop: false pairs well with onboarding flows (:fullscreen variant) where users should feel a definite 'first' and 'last' step.

Responsive Items Per View

items_per_view accepts an Integer or a breakpoint hash. The Stimulus controller listens for window resize (debounced 100ms) and rewrites the --carousel-slide-basis CSS variable on the root.

Integer (fixed across breakpoints)

items_per_view: Integer
<%= rui_carousel(id: "two-up", variant: :cards, items_per_view: 2) do |c| %>
  <% # always 2-up, regardless of viewport %>
<% end %>

Breakpoint hash (responsive)

Resize your browser window to see the slide count change between 1 / 2 / 3 / 4 columns. Breakpoints match Tailwind: sm=640, md=768, lg=1024, xl=1280.

items_per_view: Hash
<%= rui_carousel(id: "responsive",
                 variant: :cards,
                 items_per_view: { base: 1, sm: 2, md: 3, lg: 4 }) do |c| %>
  <% # slide count adapts to viewport width %>
<% end %>

Breakpoint fallback: if you only specify md:, the controller falls back to 1 below that breakpoint. Always include a base: entry to be explicit.

Colors

The color: kwarg tints the active indicator dot and the prev/next arrow buttons. Accepts semantic names (:primary, :success, :warning, :danger, :info) or any Tailwind color.

Semantic colors

Primary

Success

Warning

Danger

Tailwind colors

Rose

Emerald

Indigo

Amber

Colors
<%# Semantic %>
<%= rui_carousel(id: "primary", color: :primary) do |c| ... end %>
<%= rui_carousel(id: "success", color: :success) do |c| ... end %>

<%# Tailwind palette %>
<%= rui_carousel(id: "rose", color: :rose) do |c| ... end %>
<%= rui_carousel(id: "emerald", color: :emerald) do |c| ... end %>
<%= rui_carousel(id: "indigo", color: :indigo) do |c| ... end %>

Sizes

size: drives indicator dot and arrow button dimensions. Three steps: :sm, :base (default), :lg.

size: :sm

size: :base

size: :lg

Sizes
<%= rui_carousel(id: "small", size: :sm) do |c| ... end %>
<%= rui_carousel(id: "base", size: :base) do |c| ... end %>
<%= rui_carousel(id: "large", size: :lg) do |c| ... end %>

Shapes

shape: controls the slide viewport's border-radius. Use :none for sharp marketing strips, :pill for a softer, lozenge look.

shape: :none

shape: :rounded

shape: :square

shape: :pill

Shapes
<%= rui_carousel(id: "sharp", shape: :none) do |c| ... end %>
<%= rui_carousel(id: "default", shape: :rounded) do |c| ... end %>
<%= rui_carousel(id: "square", shape: :square) do |c| ... end %>
<%= rui_carousel(id: "pill", shape: :pill) do |c| ... end %>

Stimulus Controller

The carousel ships a single Stimulus controller registered as the 'carousel' identifier (or 'rui--carousel' if your app sets config.stimulus_namespace = "rui"). This section documents every public surface.

Targets

TargetElementPurpose
trackscroll containerThe flex row that holds all slides; receives scrollTo() calls and is the root for the IntersectionObserver.
slideslide wrapperEach rendered slide div; tracked by the observer for active-state.
indicatorbutton (tab)Dot button; receives aria-current updates and the goTo click action.
prevbuttonPrevious arrow; toggled disabled at start when loop:false.
nextbuttonNext arrow; toggled disabled at end when loop:false.
liveRegiondiv sr-onlyaria-live="polite" region that announces "Slide N of M" on every active change.

Values

ValueTypeDefaultNotes
autoRotateBooleanfalseDrives the setInterval timer.
intervalNumber5000Milliseconds between auto advances.
loopBooleantrueWhen false, prev/next clamp + disable at boundaries.
pauseOnHoverBooleantruemouseenter pauses; mouseleave resumes.
respectReducedMotionBooleantrueSuppresses auto-rotate when OS reports reduced motion.
itemsPerViewString (JSON)"1"Encoded items_per_view; resolved per breakpoint into a CSS variable.

Public actions

Call any of these from data-action attributes anywhere on the page using #carousel.* — the controller name (or rui--carousel# with the gem-managed namespace).

ActionWhat it does
next()Advance one slide. Wraps to 0 if loop:true; no-op at end if loop:false.
prev()Step back one slide. Wraps to last if loop:true; no-op at start if loop:false.
goTo({ index })Jump to a specific index. Reads data-carousel-index-param.
pause()Pause auto-rotate (hover semantics; no-op if pauseOnHover:false).
resume()Resume auto-rotate (hover semantics; no-op if pauseOnHover:false).
onKeydown(event)Wired automatically to the root region for ArrowLeft/Right/Home/End.
External buttons driving a carousel
<%= rui_carousel(id: "remote-controlled") do |c| %>
  <% c.with_slide(label: "One") do %>...<% end %>
  <% c.with_slide(label: "Two") do %>...<% end %>
  <% c.with_slide(label: "Three") do %>...<% end %>
<% end %>

<div class="mt-4 flex gap-2">
  <button data-action="click->carousel#prev"
          data-controller-element="#remote-controlled">
    Previous
  </button>
  <button data-action="click->carousel#goTo"
          data-carousel-index-param="2">
    Jump to slide 3
  </button>
  <button data-action="click->carousel#next">
    Next
  </button>
</div>

Lifecycle

  • connect(): caches reduced-motion preference, resolves items_per_view into a CSS variable, wires the IntersectionObserver on the track, attaches resize + visibility listeners, and starts the auto-rotate timer when applicable.
  • disconnect(): clears the timer, disconnects the IntersectionObserver, and removes both window listeners. Always paired — safe to remove the carousel from the DOM (Turbo morphing included).

Reduced motion

  • The controller calls window.matchMedia("(prefers-reduced-motion: reduce)") at connect time.
  • If it matches and respectReducedMotion is true, the auto-rotate timer never starts.
  • All scrollTo() calls fall back from behavior: "smooth" to behavior: "auto" — manual navigation also respects the preference.

Accessibility

Carousel implements the WAI-ARIA Carousel Pattern with sensible defaults; you don't have to wire anything yourself.

ARIA roles & properties

  • The root has role="region" and aria-roledescription="carousel".
  • aria-label="Carousel" by default; override with the aria_label: kwarg.
  • Each slide wraps in role="group" + aria-roledescription="slide" + aria-label="Slide N of M[: label]".
  • Indicators render as a tablist (role="tablist") of tabs; the active indicator carries aria-current="true".
  • The live region is aria-live="polite" aria-atomic="true" and announces "Slide N of M" on every active change.
  • Each control has an aria-controls="<carousel-id>" attribute pointing back at the region.

Keyboard support

KeyAction
TabMove focus to the carousel region (tabindex="0").
Arrow LeftPrevious slide.
Arrow RightNext slide.
HomeJump to first slide.
EndJump to last slide.
Tab againMove focus into the indicator tablist or arrow buttons.

Visual focus

Arrow buttons and indicators ship focus-visible:ring-2 with the active color tint. Always verify your slide content also has visible focus rings if it contains interactive elements.

Reduced motion

Documented above under Stimulus. Auto-rotate is opt-out via the OS setting by default; do not set respect_reduced_motion: false unless you have a hard business reason.

Form Integration

Carousel is not a form input and intentionally has no form integration. If you need users to pick a slide as a form value (e.g. "select your preferred plan"), drive a hidden <input> from a Stimulus controller of your own that listens to clicks on the indicators and writes the chosen index. Alternatively, use rui_steps for true multi-step form flows.

Turbo Integration

Carousel does not lazy-load slides itself — every slide ships in the initial HTML. If you want lazy slide content, drop a turbo_frame_tag inside a slide and let Turbo handle the request.

Slide content via Turbo Frame
<%= rui_carousel(id: "lazy-cards", variant: :cards) do |c| %>
  <% @posts.each do |post| %>
    <% c.with_slide(label: post.title) do %>
      <%= turbo_frame_tag dom_id(post),
            src: post_path(post),
            loading: "lazy" %>
    <% end %>
  <% end %>
<% end %>

Turbo Streams + carousel

When a Turbo Stream replaces or appends a slide, the carousel's controller reconnects cleanly (disconnect → connect) and re-resolves slide count, active index, and items_per_view. No manual cleanup needed.

Turbo Morph + carousel

Auto-rotate timers and IntersectionObservers are tied to the Stimulus lifecycle, so morphing in/out is safe. If you swap the carousel root, the new instance starts fresh at index 0.

Common Patterns

Hero banner with overlay text + CTA

Hero with overlay CTA
<%= rui_carousel(id: "hero", variant: :hero, auto_rotate: true) do |c| %>
  <% c.with_slide(label: "Catalogue") do %>
    <div class="relative h-[600px]">
      <img src="/hero.jpg" class="w-full h-full object-cover" loading="eager" />
      <div class="absolute inset-0 bg-gradient-to-r from-black/70 to-transparent"></div>
      <div class="absolute inset-0 flex items-center px-12">
        <div class="text-white">
          <h2 class="text-4xl font-bold">Browse the catalogue</h2>
          <%= rui_button("Open catalogue", color: :primary, size: :lg, class: "mt-4") %>
        </div>
      </div>
    </div>
  <% end %>
<% end %>

Testimonials with avatar, quote, author

Testimonials pattern
<%= rui_carousel(id: "quotes",
                 variant: :testimonials,
                 color: :indigo,
                 auto_rotate: true,
                 interval: 7000) do |c| %>
  <% @testimonials.each do |t| %>
    <% c.with_slide(label: t.author) do %>
      <article class="p-6 bg-white rounded-xl border h-full">
        <div class="flex items-center gap-3 mb-3">
          <%= image_tag t.avatar_url, class: "w-12 h-12 rounded-full" %>
          <div>
            <p class="font-medium"><%= t.author %></p>
            <p class="text-xs text-zinc-500"><%= t.role %></p>
          </div>
        </div>
        <p>&ldquo;<%= t.quote %>&rdquo;</p>
      </article>
    <% end %>
  <% end %>
<% end %>

Product grid that becomes a carousel below md

Use items_per_view: 1 on small viewports and 3+ on md+ — the user gets a clean grid on desktop and a swipeable carousel on mobile, no media query CSS required.

Mobile-first product grid
<%= rui_carousel(id: "products",
                 variant: :cards,
                 items_per_view: { base: 1, md: 3 }) do |c| %>
  <% @products.each do |p| %>
    <% c.with_slide(label: p.name) do %>
      <%= render p %>
    <% end %>
  <% end %>
<% end %>

API Reference

Every kwarg accepted by rui_carousel. Source of truth: app/components/rapid_rails_ui/carousel/component.rb in the gem.

Rotating slide region built on CSS scroll-snap and a single Stimulus controller. Six variants, fully responsive, accessible.

Name Type Default Description
id required String nil Required DOM id. Used by aria-controls and JS targeting; must be unique on the page.

Appearance

Visual styling options

Name Type Default Description
variant Symbol :default Variant preset that drives layout defaults
Options: :default, :banner, :hero, :cards, :testimonials, :fullscreen
color Symbol :primary Indicator + arrow tint. Semantic name or any Tailwind color.
size Symbol :base Indicator + arrow size
Options: :sm, :base, :lg
shape Symbol :rounded Slide viewport corner radius
Options: :none, :rounded, :square, :pill
gap Symbol :base Gap between slides
Options: :none, :sm, :base, :lg
aria_label String "Carousel" aria-label on the region element

Behavior

Auto-rotate, looping, and motion preferences

Name Type Default Description
auto_rotate Boolean false Auto-advance slides on an interval
interval Integer 5000 Auto-rotate interval in milliseconds
loop Boolean true Wrap at boundaries. When false, prev/next disable at edges.
pause_on_hover Boolean true Pause auto-rotate when the cursor enters the region
respect_reduced_motion Boolean true Suppress auto-rotate when OS prefers reduced motion

Layout

Per-viewport sizing and control positioning

Name Type Default Description
items_per_view Integer or Hash variant default Number of slides visible. Integer or { base:, sm:, md:, lg:, xl: } hash.
show_arrows Boolean variant default Show prev/next arrow buttons. nil uses the variant default.
show_indicators Boolean true Show dot indicators below or overlaid on slides
arrow_position Symbol variant default Where arrow buttons render
Options: :sides, :overlay, :outside
indicator_position Symbol variant default Where indicator dots render
Options: :outside, :overlay_bottom, :overlay_bottom_right

Slide Slot

Options for carousel.with_slide()

Name Type Default Description
label String nil Optional human-readable name for the slide. Appended to the slide's aria-label and the indicator's aria-label.

Stimulus Values

Data-* values written to the root element

Name Type Default Description
autoRotate Boolean false Mirrors :auto_rotate
interval Number 5000 Mirrors :interval
loop Boolean true Mirrors :loop
pauseOnHover Boolean true Mirrors :pause_on_hover
respectReducedMotion Boolean true Mirrors :respect_reduced_motion
itemsPerView String (JSON) "1" JSON-encoded items_per_view, e.g. "1" or '{"base":1,"md":3}'

Slots

NameDescription
slidesMultiple slides via carousel.with_slide(label: nil) { block }. Slide content can be any markup (image, card, video, partial render).

Troubleshooting

Auto-rotate doesn't start

Most likely cause: Your OS is set to "reduce motion" and respect_reduced_motion: true (the default) is honouring that preference.

Check System Settings → Accessibility → Display → Reduce motion (macOS), Settings → Accessibility → Animation effects (Windows), or your browser's prefers-reduced-motion emulation in DevTools.

If you genuinely need to override the preference (rare), set respect_reduced_motion: false.

Slides don't snap correctly

Most likely cause: Your items_per_view JSON is producing fractional slide widths that don't line up with the scroll-snap points.

Inspect the root element in DevTools — the inline style should read something like --carousel-slide-basis: 33.333%. If it's blank, the controller failed to parse the value.

Always pass numeric values (Integer or numeric in the hash). Strings like "three" silently fall back to 1.

Indicators not clickable

Most likely cause: The Stimulus controller isn't registered, so the data-action attributes on the indicators are inert.

Open the browser console. If you see "Application#identifier: missing controller", verify carousel_controller.js is imported and application.register("carousel", CarouselController) is called in your controllers/index.js (or that registerAll(application, { prefix: "rui" }) ran in the gem-managed block).

Also confirm the controller isn't being shadowed by another carousel implementation registered against the same identifier.

Multiple carousels conflict

Most likely cause: Two carousels share the same id:.

Every rui_carousel needs a unique DOM id — it's used by aria-controls, focus management, and JS targeting. Duplicate IDs cause every indicator to control whichever carousel the browser found first.

Use a stable, descriptive id (id: "home-hero", not id: "carousel") and assert uniqueness in your test suite if you generate them dynamically.

Touch swipe not working

Most likely cause: Touch swipe relies entirely on native scroll-snap-type: x mandatory. If any ancestor of the track element sets overflow: hidden on the X axis, the track can't scroll at all.

In DevTools, walk the parents of the carousel and look for overflow-x: hidden or a fixed-height container that's clipping the scroll. Set the container to overflow-x: visible or remove the clipping rule.

Also verify touch-action isn't being overridden globally; the carousel needs touch-action: pan-x (or default).

Layout shifts on initial paint

Most likely cause: Slide images don't declare width/height (or aspect ratio), so they pop in once decoded.

Add explicit dimensions or wrap each slide in a fixed-aspect container (e.g. class="aspect-[3/1]"). Set loading="eager" on the first slide to keep LCP fast.