Carousel

Slides that shrink and wait behind the neighbours away from the center, dots that stretch from one to the next and a sliding counter. A drop-in replacement for embla-carousel-react, with the same API.

1
2
3
4
5
Slide 1 of 0
"use client"

import * as React from "react"
import { cn } from "cn"
import useEmblaCarousel, {
  type UseEmblaCarouselType,
} from "@animate-ui/react/primitives/carousel"

import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"

import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"

type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]

type CarouselProps = {
  opts?: CarouselOptions
  plugins?: CarouselPlugin
  orientation?: "horizontal" | "vertical"
  setApi?: (api: CarouselApi) => void
}

type CarouselContextProps = {
  carouselRef: ReturnType<typeof useEmblaCarousel>[0]
  api: ReturnType<typeof useEmblaCarousel>[1]
  scrollPrev: () => void
  scrollNext: () => void
  canScrollPrev: boolean
  canScrollNext: boolean
} & CarouselProps

const CarouselContext = React.createContext<CarouselContextProps | null>(null)

function useCarousel() {
  const context = React.useContext(CarouselContext)

  if (!context) {
    throw new Error("useCarousel must be used within a <Carousel />")
  }

  return context
}

function Carousel({
  orientation = "horizontal",
  opts,
  setApi,
  plugins,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & CarouselProps) {
  const [carouselRef, api] = useEmblaCarousel(
    {
      ...opts,
      axis: orientation === "horizontal" ? "x" : "y",
    },
    plugins
  )
  const [canScrollPrev, setCanScrollPrev] = React.useState(false)
  const [canScrollNext, setCanScrollNext] = React.useState(false)

  const onSelect = React.useCallback((api: CarouselApi) => {
    if (!api) return
    setCanScrollPrev(api.canScrollPrev())
    setCanScrollNext(api.canScrollNext())
  }, [])

  const scrollPrev = React.useCallback(() => {
    api?.scrollPrev()
  }, [api])

  const scrollNext = React.useCallback(() => {
    api?.scrollNext()
  }, [api])

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (event.key === "ArrowLeft") {
        event.preventDefault()
        scrollPrev()
      } else if (event.key === "ArrowRight") {
        event.preventDefault()
        scrollNext()
      }
    },
    [scrollPrev, scrollNext]
  )

  React.useEffect(() => {
    if (!api || !setApi) return
    setApi(api)
  }, [api, setApi])

  React.useEffect(() => {
    if (!api) return
    onSelect(api)
    api.on("reInit", onSelect)
    api.on("select", onSelect)

    return () => {
      api?.off("select", onSelect)
    }
  }, [api, onSelect])

  return (
    <CarouselContext.Provider
      value={{
        carouselRef,
        api: api,
        opts,
        orientation:
          orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
        scrollPrev,
        scrollNext,
        canScrollPrev,
        canScrollNext,
      }}
    >
      <div
        onKeyDownCapture={handleKeyDown}
        className={cn("relative", className)}
        role="region"
        aria-roledescription="carousel"
        data-slot="carousel"
        {...props}
      >
        {children}
      </div>
    </CarouselContext.Provider>
  )
}

function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
  const { carouselRef, orientation } = useCarousel()

  return (
    <div
      ref={carouselRef}
      className="overflow-hidden"
      data-slot="carousel-content"
    >
      <div
        className={cn(
          "flex",
          orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
          className
        )}
        {...props}
      />
    </div>
  )
}

function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
  const { orientation } = useCarousel()

  return (
    <div
      role="group"
      aria-roledescription="slide"
      data-slot="carousel-item"
      className={cn(
        "min-w-0 shrink-0 grow-0 basis-full",
        orientation === "horizontal" ? "pl-4" : "pt-4",
        className
      )}
      {...props}
    />
  )
}

function CarouselPrevious({
  className,
  variant = "outline",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollPrev, canScrollPrev } = useCarousel()

  return (
    <Button
      data-slot="carousel-previous"
      variant={variant}
      size={size}
      className={cn(
        "absolute touch-manipulation rounded-full",
        orientation === "horizontal"
          ? "inset-y-0 -left-12 my-auto"
          : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollPrev}
      onClick={scrollPrev}
      {...props}
    >
      <HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} className="cn-rtl-flip" />
      <span className="sr-only">Previous slide</span>
    </Button>
  )
}

function CarouselNext({
  className,
  variant = "outline",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollNext, canScrollNext } = useCarousel()

  return (
    <Button
      data-slot="carousel-next"
      variant={variant}
      size={size}
      className={cn(
        "absolute touch-manipulation rounded-full",
        orientation === "horizontal"
          ? "inset-y-0 -right-12 my-auto"
          : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
        className
      )}
      disabled={!canScrollNext}
      onClick={scrollNext}
      {...props}
    >
      <HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="cn-rtl-flip" />
      <span className="sr-only">Next slide</span>
    </Button>
  )
}

export {
  type CarouselApi,
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
  useCarousel,
}

import {
  CarouselCounter,
  CarouselDots,
} from "@animate-ui/react/primitives/carousel"

const on = (value: boolean | string) => String(value) === "true"

export default function CarouselDemo({
  scale = 0.8,
  visible = 3,
  loop = true,
  dots = true,
  counter = true,
}: {
  scale?: number | string
  visible?: number | string
  loop?: boolean | string
  dots?: boolean | string
  counter?: boolean | string
}) {
  const shown = Number(visible)
  // A loop needs a slide more than it shows on each side.
  const count = Math.max(5, 2 * Math.floor(shown / 2) + 3)
  // The row's width in slides: the selected one, then the smaller ones out.
  let row = 1
  for (let n = 1; n <= (shown - 1) / 2; n++) row += 2 * Number(scale) ** n
  if (shown % 2 === 0) row += Number(scale) ** (shown / 2)

  return (
    <div className="flex w-full flex-col items-center gap-4">
      <Carousel
        key={`${on(loop)}-${count}`}
        opts={{
          loop: on(loop),
          align: "center",
          containScroll: false,
          scale: Number(scale),
          visible: shown,
        }}
        className="w-full"
        style={{ maxWidth: `min(${16 * row}rem, 100% - 7rem)` }}
      >
        <CarouselContent className="-ml-1">
          {Array.from({ length: count }).map((_, index) => (
            <CarouselItem key={index} className="pl-1">
              <div>
                <Card className="ring-inset ring-[color-mix(in_oklab,var(--foreground)_10%,var(--card))]">
                  <CardContent className="flex aspect-[13/15] items-center justify-center p-6">
                    <span className="text-4xl font-semibold select-none">{index + 1}</span>
                  </CardContent>
                </Card>
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
        {(on(dots) || on(counter)) && (
          <div className="mt-4 flex flex-col items-center gap-2">
            {on(dots) && <CarouselDots />}
            {on(counter) && (
              <CarouselCounter className="text-sm text-muted-foreground" />
            )}
          </div>
        )}
      </Carousel>
    </div>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add carousel
pnpm dlx shadcn@latest add carousel
yarn dlx shadcn@latest add carousel
bun x shadcn@latest add carousel

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate carousel
pnpm dlx @animate-ui/cli@latest animate carousel
yarn dlx @animate-ui/cli@latest animate carousel
bun x @animate-ui/cli@latest animate carousel

shadcn/ui's Carousel is built on Embla Carousel. Animate UI's carousel exports exactly the same useEmblaCarousel and types and adds the animation, set from opts. animate-ui animate carousel swaps the embla-carousel-react import for it. It is built on embla-carousel-react, so keep that dependency (shadcn add carousel installs it).

Usage

import {
  CarouselCounter,
  CarouselDots,
} from '@animate-ui/react/primitives/carousel';

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';

export function Gallery() {
  return (
    <Carousel opts={{ loop: true, align: 'center', scale: 0.8, visible: 3 }}>
      <CarouselContent className="-ml-1">
        {Array.from({ length: 5 }).map((_, index) => (
          <CarouselItem key={index} className="pl-1">
            <img
              src={`/photo-${index}.jpg`}
              className="aspect-square rounded-xl object-cover"
            />
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
      <CarouselDots />
      <CarouselCounter />
    </Carousel>
  );
}

Everything else is shadcn/ui's and Embla's: parts, sizes, spacing, orientation, options, API, events and plugins. Without scale and visible, it lays out and moves exactly like shadcn/ui's.

  • scale is the neighbours' scale, from 0 to 1: the slides shrink away from the center (1 or none leaves them as they are). Use it with align: 'center', in a loop for slides on both sides (without one, add containScroll: false so the first and last slides come to the center too). It scales each slide's content, its first child, so give each CarouselItem one.
  • visible sizes the slides so that many show, the selected one in the middle, edge to edge with scale, keeping your spacing (pl-* on the items). With an even number, the slides at the edges show half. A loop needs a slide more than it shows on each side: at least 5 slides for 3 visible, 7 for 4 or 5. The slides are sized once the carousel mounts: on a server-rendered page, give CarouselItem a close basis to start from.
  • The edges clip what overflows them, so draw a slide's outline inside it (a border or an inset ring).
  • The arrows sit along the slides, whatever you put under them, standing up too.
  • The slides out of sight, behind the others or past the edges, are inert and aria-hidden until they show.
  • CarouselDots and CarouselCounter come from the package: place them anywhere inside Carousel. Style them with classes (the dots and pill take currentColor). CarouselCounter takes a format for its text.

Animation

  • With scale, the slides follow the scroll, frame by frame, while dragged or snapping: the selected one at its full size, each slide out scale times smaller (0.8, then 0.64) and drawn in to keep the gap between slides, out to the ones visible shows; the ones past them wait behind, a little smaller still, and slide out from behind as they come in. In a loop, each slide takes the nearest way around, so both sides stay filled. It works the same standing up (orientation="vertical", with a height on CarouselContent). Each slide sits above the ones farther from the center.
  • CarouselDots: the current dot is a pill that follows the scroll, its front stretching to the next dot over the first half of the way and its back catching up over the second. In a loop, from the last slide to the first, it stretches back across all the dots the same way.
  • CarouselCounter: the number slides to the next one (Sliding Number).
  • Under reduced motion, the slides keep their size and the counter's number changes at once.

API Reference

useEmblaCarousel takes Embla's options and plugins; shadcn/ui's Carousel passes its opts to it. Animate UI adds:

opts

PropTypeDefault
scale?
number-
visible?
number-

CarouselDots

The dots, one per slide. Takes the props of a div.

CarouselCounter

PropTypeDefault
format?
(current: React.ReactNode, count: number) => React.ReactNode"Slide {current} of {count}"

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/27/2026