SwipiDocumentation

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

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.

tsx
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

EmblaTypeDescription
opts.loopbooleanThe same option name and the same meaning — loop passes straight through.
opts.dragFreebooleanAlso unchanged. So are startIndex and the callbacks.
plugins={[Autoplay()]}pluginBecomes autoplay and autoplaySpeed on the same options object — no plugin to install.
setApi / apiCarouselApiBecomes 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.

tsx
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.

@ Copyright 2026 Midstem. All rights reserved.

DocsMidstemGitHubLinkedin