“Cut our onboarding time in half — the team finally has a single source of truth.”
Jane Patel
Operations
A flexible, accessible slide carousel built on CSS scroll-snap and a single Stimulus controller. One component, six variants, fully responsive.
:default, :banner, :hero, :cards, :testimonials, :fullscreen{ base:, sm:, md:, lg:, xl: } hashrole="region", aria-roledescription, live region, indicator aria-currentnext(), prev(), goTo(), pause(), resume()Carousel is a Pro component. Once your RRUI Pro license is activated, the helper is available everywhere.
<%= 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 %>
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.
<%= 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 %>
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 |
|---|---|---|---|---|---|
:default | Generic 1-up | 1 | yes | :sides | :outside |
:banner | Full-bleed promo | 1 | no | :overlay | :overlay_bottom_right |
:hero | Marketing splash | 1 | no | :overlay | :overlay_bottom_right |
:cards | Multi-card slider | { base:1, sm:2, md:3 } | yes | :outside | :outside |
:testimonials | Quote cards | { base:1, md:3 } | yes | :outside | :outside |
:fullscreen | 100vh slide deck | 1 | yes | :overlay | :overlay_bottom |
:default — Generic 1-upThe all-purpose variant. Side arrows, dots below. Reach for this when nothing else fits.
<%= 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 promoFull-width promotional strip. Indicators overlay bottom-right; arrows fade in over the slide when shown. Pair with auto_rotate for a hands-off rotator.
<%= 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 splashSame 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.
<%= 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 sliderResponsive 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.
<%= 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 cardsThree quote cards on md+, single column on mobile. Tuned for short testimonial copy and a single attribution line.
<%= 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>“<%= t.body %>”</p>
<p class="mt-4 font-medium"><%= t.author %></p>
</article>
<% end %>
<% end %>
<% end %>
:fullscreen — 100vh deckSets 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.
<%= 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 %>
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.
label: parameterOpen 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'.
<%= 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 %>
Slides are generic wrappers — pass any markup. Images, cards, partials, Turbo Frames, Lookbook previews — all work.
<%= 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 %>
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
<%# 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: true (default) pauses the timer when the cursor enters the region. Hover the demo above to see it freeze.
<%= rui_carousel(id: "always-rotating",
auto_rotate: true,
interval: 4000,
pause_on_hover: false) do |c| %>
<% # slides %>
<% end %>
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).
<%# 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: 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)
<%# 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.
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.
<%= rui_carousel(id: "two-up", variant: :cards, items_per_view: 2) do |c| %>
<% # always 2-up, regardless of viewport %>
<% end %>
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.
<%= 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.
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.
Primary
Success
Warning
Danger
Rose
Emerald
Indigo
Amber
<%# 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 %>
size: drives indicator dot and arrow button dimensions. Three steps: :sm, :base (default), :lg.
size: :sm
size: :base
size: :lg
<%= 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 %>
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
<%= 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 %>
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.
| Target | Element | Purpose |
|---|---|---|
track | scroll container | The flex row that holds all slides; receives scrollTo() calls and is the root for the IntersectionObserver. |
slide | slide wrapper | Each rendered slide div; tracked by the observer for active-state. |
indicator | button (tab) | Dot button; receives aria-current updates and the goTo click action. |
prev | button | Previous arrow; toggled disabled at start when loop:false. |
next | button | Next arrow; toggled disabled at end when loop:false. |
liveRegion | div sr-only | aria-live="polite" region that announces "Slide N of M" on every active change. |
| Value | Type | Default | Notes |
|---|---|---|---|
autoRotate | Boolean | false | Drives the setInterval timer. |
interval | Number | 5000 | Milliseconds between auto advances. |
loop | Boolean | true | When false, prev/next clamp + disable at boundaries. |
pauseOnHover | Boolean | true | mouseenter pauses; mouseleave resumes. |
respectReducedMotion | Boolean | true | Suppresses auto-rotate when OS reports reduced motion. |
itemsPerView | String (JSON) | "1" | Encoded items_per_view; resolved per breakpoint into a CSS variable. |
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).
| Action | What 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. |
<%= 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>
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).window.matchMedia("(prefers-reduced-motion: reduce)") at connect time.respectReducedMotion is true, the auto-rotate timer never starts.scrollTo() calls fall back from behavior: "smooth" to behavior: "auto" — manual navigation also respects the preference.Carousel implements the WAI-ARIA Carousel Pattern with sensible defaults; you don't have to wire anything yourself.
role="region" and aria-roledescription="carousel".aria-label="Carousel" by default; override with the aria_label: kwarg.role="group" + aria-roledescription="slide" + aria-label="Slide N of M[: label]".role="tablist") of tabs; the active indicator carries aria-current="true".aria-live="polite" aria-atomic="true" and announces "Slide N of M" on every active change.aria-controls="<carousel-id>" attribute pointing back at the region.| Key | Action |
|---|---|
| Tab | Move focus to the carousel region (tabindex="0"). |
| ← Arrow Left | Previous slide. |
| → Arrow Right | Next slide. |
| Home | Jump to first slide. |
| End | Jump to last slide. |
| Tab again | Move focus into the indicator tablist or arrow buttons. |
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.
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.
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.
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.
<%= 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 %>
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.
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.
<%= 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 %>
<%= 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>“<%= t.quote %>”</p>
</article>
<% end %>
<% end %>
<% end %>
mdUse 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.
<%= 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 %>
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. |
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 |
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 |
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 |
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. |
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}' |
| Name | Description |
|---|---|
slides | Multiple slides via carousel.with_slide(label: nil) { block }. Slide content can be any markup (image, card, video, partial render). |
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.
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.
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.
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.
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).
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.