[Feature] Carousel: match shadcn (Base UI + vega)

Merged
CI
2d ago
+399-71

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's data-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-disabled in sync with disabled. Since #607 Button renders both, and the controller only flipped disabled, so enabled buttons kept data-disabled.
  • A caller's class now overrides the orientation classes. CarouselItem(class: "pl-1") used to stay at 16px: group-[.is-horizontal]:pl-4 has 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's cn. 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 passes loop: 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

Closed (light)

Closed (dark)

Keyboard focus on Next

After 4× Next

Vertical, -mt-1 / pt-1 from the caller

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

  1. cd gem && bundle exec rake
  2. cd 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.
  3. /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.