[Feature] Carousel: match shadcn (Base UI + vega)
What changes
- Previous and Next use lucide chevrons instead of arrows, mirrored in RTL, and get
touch-manipulation. Their accessible names now match shadcn. Previous used to say "Next slide", and Next had no name at all, which WAI-ARIA 1.2 doesn't allow on a button. - Every part has a
data-slot:carousel,carousel-content(on the viewport),carousel-item,carousel-previous,carousel-next. Previous and Next replace Button'sdata-slot="button"rather than appending to it. - The carousel no longer loops by default, matching shadcn and Embla's default. Previous is disabled on the first slide and Next on the last. This is a behavior change: pass
options: {loop: true}to keep wrapping. - The controller keeps
data-disabledin sync withdisabled. Since #607 Button renders both, and the controller only flippeddisabled, so enabled buttons keptdata-disabled. - A caller's class now overrides the orientation classes.
CarouselItem(class: "pl-1")used to stay at 16px:group-[.is-horizontal]:pl-4has higher specificity, and tailwind_merge keeps both. Carousel now passes its orientation to the parts through the Phlex render context (as Accordion does), so they emit plain utilities (pl-4,-ml-4,pt-4…) that a caller's class replaces, as with shadcn'scn. Before this, the docs "Spacing" example had no effect. - Docs: every example passes
aria_label, since a region needs a name (WAI-ARIA 1.2 #region). There's a new "Loop" example, and the first example no longer passesloop: false. - Audit: carousel joins the shadcn audit (component map and fixtures), with a Props divergence for
plugins/setApi. Both take JS objects that a Phlex kwarg can't carry.
Before / after





Audit checklists
main (af07643d)
shadcn audit ruby-ui/ruby_ui
base af07643d head af07643d shadcn c0d63c33
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme neutral tokens match shadcn
Carousel (ref: bases/base/ui/carousel.tsx + style-vega.css @ c0d63c33)
✅ Look transform: matrix(1, 0, 0, 1, 0, 0)
CarouselItem — closed/light, closed/dark. Override: an identity matrix renders the same as none. Embla sets transform: translate3d(0,0,0) on each slide only because RubyUI turns loop on (see Behavior).
↳ computed styles (shadcn-audit collect)
❌ Look opacity: 1
vega: opacity: 0.5
CarouselPrevious — closed/light, closed/dark. Opacity 0.5 is shadcn's disabled Previous at slide 1; RubyUI's stays enabled because of loop (see Behavior). The Button outline itself matches since #607. Requested: drop the default loop (Behavior) so Previous starts disabled at 50%.
↳ bases/base/ui/carousel.tsx:194
⏸ Look properties: font-family
Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext — explained by environment diff: font Kept as is: environment difference (docs font stack), Inter Variable loads on both sides.
❌ Look lucide arrow-left / arrow-right (two paths, w-4 h-4), no RTL flip
vega: lucide chevron-left / chevron-right + cn-rtl-flip
CarouselPrevious, CarouselNext: the icon glyph differs on screen (arrows vs chevrons). Size is 16px on both ([&_svg:not([class*='size-'])]:size-4 in vega). Requested: lucide chevron-left/right paths, drop w-4 h-4 (Button applies size-4), add rtl:rotate-180.
↳ bases/base/ui/carousel.tsx:199
✅ Look Button outline: shadow-xs, dark:bg-input/30, focus-visible ring-3 ring-ring/50 border-ring
CarouselPrevious, CarouselNext: matches since #607 (Button: match shadcn). Focus measured on Next after Tab: 3px oklab(0.708 0 0 / 0.5) ring and oklch(0.708 0 0) border on both sides. Resolved by merging main (#607, Button: match shadcn).
↳ style-vega.css:146
❌ Look touch-action: auto
vega: touch-manipulation (touch-action: manipulation)
CarouselPrevious, CarouselNext: without it, a double tap on a button may zoom on mobile instead of scrolling twice. Requested: add touch-manipulation to CarouselPrevious and CarouselNext.
↳ bases/base/ui/carousel.tsx:188
✅ Look absolute h-8 w-8 rounded-full, top-1/2 -translate-y-1/2, -left-12/-right-12
CarouselPrevious, CarouselNext: different tokens, same boxes in the browser: 32x32 at x=16,y=208 and x=400,y=208 on both sides. Radius rounded-full on both (style-vega.css:269).
↳ style-vega.css:193
✅ Look Carousel relative; CarouselContent overflow-hidden > flex -ml-4; CarouselItem basis-full pl-4
Carousel, CarouselContent, CarouselItem: no computed-style difference besides the font (environment) and the loop transform above. RubyUI reaches orientation through group-[.is-horizontal]/[.is-vertical] instead of a context ternary.
↳ computed styles (shadcn-audit collect)
❌ HTML no data-slot on Carousel, CarouselContent, CarouselItem; Previous/Next carry Button's data-slot="button"
shadcn: data-slot carousel, carousel-content, carousel-item, carousel-previous, carousel-next
shadcn overrides Button's slot with carousel-previous/next (bases/base/ui/carousel.tsx:184, :221). Same gap the Breadcrumb PR (#606) fixed. Requested: data-slot on every part; Previous/Next override Button's "button" with carousel-previous/next; map selectors move to [data-slot=…].
↳ bases/base/ui/carousel.tsx:126
❌ HTML enabled Previous/Next keep data-disabled (server renders disabled: true; the controller flips only .disabled)
shadcn: data-disabled only on a disabled button (Previous at slide 1)
Live DOM after connect: RubyUI disabled=false + data-disabled on both buttons. Any data-disabled: styling would apply to enabled buttons. Requested: #toggleButtonsDisabledState sets button.disabled and toggleAttribute("data-disabled") together.
↳ bases/base/ui/carousel.tsx:194
✅ HTML div[role=region] > div.overflow-hidden > div.flex > div[role=group]*5, button[type=button] x2
Tags and nesting match; CarouselContent's slot sits on the overflow-hidden viewport on both sides.
↳ sandbox DOM
✅ Props options:, orientation:, variant:/size: forwarded to Button
From the map and source: CarouselPrevious/Next pass **attrs to Button, so variant:/size: reach it. Defaults differ in name only: shadcn size icon-sm, RubyUI icon: true + h-8 w-8.
↳ bases/base/ui/carousel.tsx:18
❌ Props no plugins kwarg (controller's initCarousel takes plugins but nothing passes them)
shadcn: plugins
Autoplay, wheel gestures etc. need a subclassed controller in RubyUI. Suggested divergence (Props): no plugins:/setApi:; extend the Stimulus controller or read its carousel property. Both carry JS objects/functions a Phlex kwarg can't.
↳ bases/base/ui/carousel.tsx:19
❌ Props no setApi
shadcn: setApi
React callback that hands out the Embla API. The Stimulus counterpart would be the controller's carousel property via an outlet; likely a divergence rather than a kwarg. Suggested divergence (Props): no plugins:/setApi:; extend the Stimulus controller or read its carousel property. Both carry JS objects/functions a Phlex kwarg can't.
↳ bases/base/ui/carousel.tsx:21
⚠️ ARIA snapshot: only here: - button "Next slide": | - img | - text: Next slide | - button: | - img
shadcn: snapshot: only here: - button "Previous slide" [disabled] | - button "Next slide"
Carousel — closed/light, closed/dark. CarouselPrevious's sr-only text says "Next slide" (carousel_previous.rb:10); CarouselNext has no sr-only text, so its button has no name. shadcn's Previous is [disabled] at slide 1, RubyUI's isn't (loop). Requested: sr-only "Previous slide" on Previous and "Next slide" on Next; the [disabled] difference follows the loop fix.
↳ bases/base/ui/carousel.tsx:194
⚠️ ARIA snapshot: only here: - button "Next slide": | - img | - text: Next slide
shadcn: snapshot: only here: - button "Previous slide" [disabled]
CarouselPrevious — closed/light, closed/dark. CarouselPrevious's sr-only text says "Next slide" (carousel_previous.rb:10); CarouselNext has no sr-only text, so its button has no name. shadcn's Previous is [disabled] at slide 1, RubyUI's isn't (loop). Requested: sr-only "Previous slide" on Previous and "Next slide" on Next; the [disabled] difference follows the loop fix.
↳ bases/base/ui/carousel.tsx:206
⚠️ ARIA snapshot: only here: - button: | - img
shadcn: snapshot: only here: - button "Next slide"
CarouselNext — closed/light, closed/dark. CarouselPrevious's sr-only text says "Next slide" (carousel_previous.rb:10); CarouselNext has no sr-only text, so its button has no name. shadcn's Previous is [disabled] at slide 1, RubyUI's isn't (loop). Requested: sr-only "Previous slide" on Previous and "Next slide" on Next; the [disabled] difference follows the loop fix.
↳ bases/base/ui/carousel.tsx:243
✅ ARIA CarouselContent, CarouselItem: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
⚠️ Behavior loop: true by default: Previous at slide 1 goes to 5, Next at 5 goes to 1; Previous/Next never disabled; Tab stops on both buttons
shadcn: no loop: stops at 1 and 5; Previous disabled at 1, Next disabled at 5 (focus falls to body when the focused Next disables)
Seen in the browser (behavior2.json, screens/carousel-end-*). RubyUI's default comes from DEFAULT_OPTIONS in carousel_controller.js. Requested: remove loop: true from DEFAULT_OPTIONS; options: {loop: true} opts in; changelog note and a loop example in the docs.
↳ bases/base/ui/carousel.tsx:54
✅ Behavior click Next/Previous moves one slide; ArrowRight/ArrowLeft with focus inside move one slide; mouse drag moves
Driven on both sides (behavior2.json). RubyUI binds keydown on the region (bubble), shadcn uses onKeyDownCapture; observed result is the same.
↳ bases/base/ui/carousel.tsx:80
⏸ Behavior vertical orientation
shadcn: vertical orientation
The fixture is horizontal only; orientation: :vertical was not exercised. Settle by hand in the fix PR: compare the docs vertical example with shadcn in the browser.
✅ Animation Embla scroll: track transform -9.14px on the first frame, -334.78px at rest
Same engine (embla-carousel) on both sides; the slide transition matches frame for frame.
↳ sandbox DOM
✅ Native Embla carousel (JS) for scrolling, Stimulus for buttons and arrows
Same engine as shadcn; no entry in native-html.md fits (CSS scroll-snap would lose canScrollPrev/Next and drag).
↳ bases/base/ui/carousel.tsx:4
⚠️ Spec div role=region with no aria-label/aria-labelledby
shadcn: div role=region with no aria-label/aria-labelledby
WAI-ARIA 1.2 region: Accessible Name Required = True; neither root has a name unless the caller passes aria-label. RubyUI copies shadcn (rule 1). APG carousel asks for aria-label/aria-labelledby too. Requested: docs examples pass aria_label: with a note that a region needs a name; component markup stays as shadcn.
↳ https://www.w3.org/TR/wai-aria-1.2/#region
⚠️ Spec slides role=group aria-roledescription=slide with no name
shadcn: same
APG recommendation: each slide has an accessible name, e.g. "3 of 5" via aria-label. Missing on both sides. Kept as is: same as shadcn; pass aria_label: on CarouselItem when needed.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/carousel/#roles_states_properties
❌ Spec CarouselNext: button with only an svg, no text, no aria-label
shadcn: button named "Next slide" (sr-only)
WAI-ARIA 1.2 button: Accessible Name Required = True; RubyUI's Next button has none (shadcn complies). CarouselPrevious has a name but the wrong one ("Next slide"). Requested: sr-only "Next slide" on CarouselNext (and "Previous slide" on Previous).
↳ https://www.w3.org/TR/wai-aria-1.2/#button
this PR
shadcn audit ruby-ui/ruby_ui
base af07643d head 29e59696 shadcn c0d63c33
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
✅ Theme neutral tokens match shadcn
Carousel (ref: bases/base/ui/carousel.tsx + style-vega.css @ c0d63c33)
⏸ Look properties: font-family
Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext — explained by environment diff: font Kept as is: environment difference (docs font stack), Inter Variable loads on both sides.
✅ Look lucide chevron-left / chevron-right, rtl:rotate-180
CarouselPrevious, CarouselNext: same glyphs on screen (screens/carousel-closed-*).
↳ bases/base/ui/carousel.tsx:199
✅ Look Button outline: shadow-xs, dark:bg-input/30, focus-visible ring-3 ring-ring/50 border-ring
CarouselPrevious, CarouselNext: focus measured on Next after Tab, identical on both sides.
↳ style-vega.css:146
✅ Look touch-action: manipulation
CarouselPrevious, CarouselNext, measured in the live DOM.
↳ bases/base/ui/carousel.tsx:188
✅ Look absolute h-8 w-8 rounded-full, -left-12/-right-12 top-1/2 -translate-y-1/2
Different tokens, same 32x32 boxes at the same positions in both orientations.
↳ style-vega.css:193
✅ Look orientation classes are plain utilities (-ml-4 / pl-4, -mt-4 flex-col / pt-4), set from the render context
A caller's class now replaces them: vertical fixture with -mt-1 / pt-1 computes margin-top -4px and padding-top 4px on both sides (was -16px / 16px).
↳ bases/base/ui/carousel.tsx:146
✅ HTML data-slot carousel, carousel-content, carousel-item, carousel-previous, carousel-next
Previous/Next replace Button's data-slot="button".
↳ bases/base/ui/carousel.tsx:126
✅ HTML data-disabled only on a disabled button
Live DOM after connect: Previous disabled + data-disabled at slide 1, Next neither, on both sides.
↳ bases/base/ui/carousel.tsx:194
✅ HTML div[role=region] > div.overflow-hidden > div.flex > div[role=group]*5, button[type=button] x2
↳ sandbox DOM
✅ Props options:, orientation:, variant:/size: forwarded to Button
↳ bases/base/ui/carousel.tsx:18
✅ Props no plugins: or setApi: — intentional
Both take JavaScript objects or functions, which a Phlex kwarg can't carry; in Stimulus the Embla API lives on the controller.
↳ audit/divergences.yml
✅ ARIA Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
✅ Behavior no loop by default: stops at 1 and 5, Previous disabled at 1, Next at 5; options: {loop: true} opts in
Driven on both sides (behavior2.json): every step identical.
↳ bases/base/ui/carousel.tsx:54
✅ Behavior click Next/Previous, ArrowRight/ArrowLeft, mouse drag move one slide
↳ bases/base/ui/carousel.tsx:80
✅ Behavior vertical orientation: same boxes, click and arrows scroll the same
Checked by hand with a temporary carousel_vertical fixture on both sides (vertical.json); not part of collect.
↳ sandbox DOM
✅ Animation Embla scroll transform frames
↳ sandbox DOM
✅ Native Embla carousel (JS) for scrolling, Stimulus for buttons and arrows
Same engine as shadcn; no native-html.md entry fits.
↳ bases/base/ui/carousel.tsx:4
⚠️ Spec div role=region named only when the caller passes aria_label
shadcn: div role=region with no aria-label/aria-labelledby
WAI-ARIA 1.2 region: Accessible Name Required = True. Markup kept as shadcn; every docs example now passes aria_label and the first one says why.
↳ https://www.w3.org/TR/wai-aria-1.2/#region
⚠️ Spec slides role=group aria-roledescription=slide with no name
shadcn: same
APG recommendation, missing on both sides. Kept as is: aria_label on CarouselItem when needed.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/carousel/#roles_states_properties
✅ Spec Previous/Next named "Previous slide" / "Next slide"
WAI-ARIA 1.2 button: Accessible Name Required = True, now met.
↳ https://www.w3.org/TR/wai-aria-1.2/#button
Two Spec ⚠️ remain on purpose, as in shadcn: the region has a name only when the caller passes aria_label, and slides have no name (an APG recommendation).
Test steps
cd gem && bundle exec rakecd docs && bin/dev, then open /docs/carousel. Previous is disabled on slide 1 and Next on slide 5, the "Spacing" example has tighter gaps than "Sizes", and "Loop" wraps from 5 to 1./shadcn-audit carousel
Release note
Minor bump: the carousel no longer loops by default. Apps that relied on it should add options: {loop: true}.
🤖 Generated with Claude Code
Summary by cubic
Brings the carousel in line with its shadcn reference (Base UI + vega). Previous and Next now use lucide chevrons mirrored in RTL, get touch-manipulation, and carry accessible names—"Previous slide" and "Next slide" (Next had none, Previous said "Next slide"). Every part has a data-slot, with Previous/Next replacing Button's data-slot="button".
Behavior change: the carousel no longer loops by default, matching shadcn and Embla's default. Previous disables at the first slide and Next at the last.
Migration
- Apps that relied on the default wrap-around should pass
options: {loop: true}.
Other changes: the controller keeps data-disabled in sync with disabled; orientation classes are now plain utilities passed through the render context, so a caller's class overrides the default spacing (the docs "Spacing" example previously had no effect); unknown orientations raise ArgumentError; chevrons mirror in RTL only when horizontal, since the vertical buttons are already rotated; every docs example passes aria_label since the region needs a name, plus a new "Loop" example (options: accepts JSON-compatible Embla values only, since they're serialized to JSON); the carousel joins the shadcn audit, with plugins/setApi documented as a divergence since both take JS objects a Phlex kwarg can't carry.