Carousel
Slides you can scroll — with the browser's own snapping where Embla was.
src/components/rahti_ui/carousel.rs
Basic
One slide at a time, with a control either side.
Five slides, one at a time. Drag it on a touch screen or a trackpad and it moves — that is the platform's own scrolling, not a script, which is the whole point of replacing Embla with scroll-snap.
Both controls start disabled and the script enables them once it has measured. That is the honest state for a reader whose JavaScript never arrives: two inert buttons above a carousel they can still scroll by hand, rather than two buttons that look live and do nothing.
Sizes
More than one slide in view, set with the basis.
`basis-1/2 lg:basis-1/3` on the slide, which is shadcn's own sizes demo and works here unchanged — the slides are laid out by flex either way, so the basis is the whole of it.
The controls move by one *slide*, not one viewport, so a half-width slide advances by half. The script measures the first slide rather than assuming, which is what makes the responsive basis work without being told about it.
Spacing
The gutter between slides, and which element the class reaches.
The gutter is a *triple* here, where shadcn's is a pair: a negative margin on the row, a matching padding on each slide, and a matching negative scroll margin so the snap points still land where the slide's content begins. `-ml-2`, `pl-2`, `-scroll-ml-2` — against the default `-ml-4`, `pl-4`, `-scroll-ml-4`.
That third one is the cost of snapping rather than transforming. Embla never needs it because it positions the row itself; a scroll port has to be told that a slide's snap edge is a gutter to the left of its border box, or every press lands a gutter short of the edge.
A `class` on `<CarouselContent>` lands on the slide *row*, not on the scroll port. That is shadcn's arrangement and the right one: the port owns the scrolling and the snapping, and is not something a caller should be restyling by accident.
Orientation
The same component running down instead of across.
`orientation="vertical"` is set once, on the region. The row, the slides and both controls all read it from there through a `group-data-[orientation=vertical]/carousel:` variant — shadcn reads the same value from a React context, and there is no context here to read.
A vertical carousel needs a height on the row, exactly as shadcn's demo does: a horizontal one is bounded by the page's width, and nothing bounds a column until you say so.
API
shadcn hands the page Embla's own object through `setApi`, and what every example then does with it is read the selected slide. There is no Embla object here — but the selection is published anyway, so the same readout is a listener away.
Slide {current} of {count}
The root publishes the selection two ways: `data-current` and `data-count` attributes for CSS and for anything reading the DOM, and a `carousel-select` event whose `detail` carries the same pair for a page that wants it in state. This readout seeds from the attributes and then keeps up with the event — the attributes are the state, the event is only the notification, and the carousel's first dispatch happens on its own mount, which may well be before a page's listener exists.
Which slide counts as current is measured rather than divided — the script takes the slide whose content edge sits nearest the port's start, so a mixed or responsive `basis` reads correctly and a caller's own gutter is accounted for.
The engine
The one component whose upstream implementation is a library, and what stands in for it.
shadcn's carousel is `embla-carousel-react`, and Embla owns the scrolling: it transforms the slide row, decides where a drag lands, snaps to the nearest slide, and publishes `canScrollPrev()` and `canScrollNext()` for the buttons. None of that is a class string, so none of it could be copied.
What replaces it is the browser's own scroll snapping, which did not exist when Embla was written. The scroll port carries `snap-mandatory` and each slide carries `snap-start`; the platform does the scrolling, the momentum and the settling. `canScrollPrev`/`canScrollNext` become `scrollLeft` against `scrollWidth - clientWidth`, and the two controls become one `scrollBy` each.
That buys a dependency-free component where the part people actually use on a phone needs no JavaScript at all, and where the slides stay real DOM in normal flow rather than a transformed row.
What is given up, plainly: Embla's *mouse* drag. Native scrolling gives touch and trackpad, but click-and-drag with a mouse is Embla's own and is not reproduced. So are `loop`, the plugin system — autoplay and friends — and the `setApi` escape hatch. Those are Embla's API rather than shadcn's design, and a carousel that needs them needs Embla.
One detail worth knowing about, because it is the kind of thing that reads as a bug: the scroll port really scrolls, so it would show a scrollbar where shadcn's shows none. It is hidden with `[scrollbar-width:none]` and the `::-webkit-scrollbar` pseudo-element — both spellings, because the standard property and the one Chrome and Safari read are not the same.
The port
Where the class strings came from, and the one place the newer file is the better fit.
The docs page's strings. The scroll port and the slide are identical in both generations; only the two controls differ, and there the newer file is both self-contained and a better fit for this library.
`size="icon-sm"` rather than `size="icon"` plus a `size-8` override — this library's Button already has an `IconSm` at `size-8`, so the newer spelling needs no override at all. The registry's would join `size-9` and `size-8` into one class list and leave Tailwind's order to pick the winner, which is exactly the trap `Pagination` had to be rebuilt around.
`inset-y-0 … my-auto` rather than `top-1/2 -translate-y-1/2`: the same centring without a transform, so it does not fight the `rotate-90` the vertical variant applies. And chevrons rather than arrows. `cn-rtl-flip` on the chevrons is dropped, as in `Pagination` and `Breadcrumb`.