Examples
shadcn/ui
The shadcn carousel components running on useSwipiCarousel instead of Embla, without touching their markup. React only.
Why it fits
The shadcn carousel puts flex -ml-4 on its container and min-w-0 shrink-0 grow-0 basis-full pl-4 on its items — the same class contract this hook measures. The engine underneath can be swapped without touching a single one of those classes, and everything built on top (CarouselItem widths, cards, the basis-1/3 breakpoints) keeps working.
components/ui/carousel.tsx
'use client'
import * as React from 'react'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useSwipiCarousel } from '@midstem/swipi-react'
import type { SwipiCarousel, SwipiCarouselOptions } from '@midstem/swipi-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type CarouselContextValue = {
carouselRef: (node: HTMLElement | null) => void
carousel: SwipiCarousel
}
const CarouselContext = React.createContext<CarouselContextValue | null>(null)
export const useCarousel = () => {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />')
}
return context
}
export const Carousel = ({
opts,
className,
children,
...props
}: React.ComponentProps<'div'> & { opts?: SwipiCarouselOptions }) => {
const [carouselRef, carousel] = useSwipiCarousel(opts)
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
carousel.scrollPrev()
}
if (event.key === 'ArrowRight') {
event.preventDefault()
carousel.scrollNext()
}
}
return (
<CarouselContext.Provider value={{ carouselRef, carousel }}>
<div
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
onKeyDownCapture={handleKeyDown}
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
export const CarouselContent = ({
className,
...props
}: React.ComponentProps<'div'>) => {
const { carouselRef } = useCarousel()
return (
<div className="overflow-hidden touch-pan-y" ref={carouselRef}>
<div className={cn('flex -ml-4', className)} {...props} />
</div>
)
}
export const CarouselItem = ({
className,
...props
}: React.ComponentProps<'div'>) => (
<div
role="group"
aria-roledescription="slide"
className={cn('min-w-0 shrink-0 grow-0 basis-full pl-4', className)}
{...props}
/>
)
export const CarouselPrevious = ({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) => {
const { carousel } = useCarousel()
return (
<Button
variant={variant}
size={size}
className={cn(
'absolute top-1/2 -left-12 size-8 -translate-y-1/2 rounded-full',
className
)}
disabled={!carousel.canScrollPrev}
onClick={carousel.scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
export const CarouselNext = ({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) => {
const { carousel } = useCarousel()
return (
<Button
variant={variant}
size={size}
className={cn(
'absolute top-1/2 -right-12 size-8 -translate-y-1/2 rounded-full',
className
)}
disabled={!carousel.canScrollNext}
onClick={carousel.scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}Usage
Call sites do not change — this is the shadcn example verbatim, with opts now carrying the hook options.
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious
} from '@/components/ui/carousel'
import { Card, CardContent } from '@/components/ui/card'
export const Gallery = ({ items }) => (
<Carousel opts={{ loop: true }} className="w-full max-w-sm">
<CarouselContent>
{items.map((item) => (
<CarouselItem key={item.id} className="md:basis-1/2 lg:basis-1/3">
<div className="p-1">
<Card>
<CardContent className="flex aspect-square items-center justify-center p-6">
<span className="text-4xl font-semibold">{item.title}</span>
</CardContent>
</Card>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
)What changes
| Embla | Type | Description |
|---|---|---|
opts.loop | boolean | The same option name and the same meaning — loop passes straight through. |
opts.dragFree | boolean | Also unchanged. So are startIndex and the callbacks. |
plugins={[Autoplay()]} | plugin | Becomes autoplay and autoplaySpeed on the same options object — no plugin to install. |
setApi / api | CarouselApi | Becomes the carousel object on the context: selectedIndex, snapCount and the three scroll methods are read directly instead of through on('select'). |
orientation | "horizontal" | "vertical" | Not supported — the track moves on the X axis only, so drop the prop and the -mt-4 flex-col branches with it. |
Adding dots
shadcn ships no dots component, because Embla needs scrollSnapList() and a subscription to build one. Here the snap count is state, so a dots component is a dozen lines against the same context.
export const CarouselDots = () => {
const { carousel } = useCarousel()
return (
<div className="mt-4 flex items-center justify-center gap-2">
{Array.from({ length: carousel.snapCount }, (_, index) => (
<button
type="button"
key={index}
aria-label={`Go to slide ${index + 1}`}
aria-current={index === carousel.selectedIndex}
onClick={() => carousel.scrollTo(index)}
className="size-2.5 rounded-full bg-muted aria-[current=true]:bg-primary"
/>
))}
</div>
)
}The pattern is the same one in the Dots guide — including the sliding indicator, if you want one.
