Carousel
A swipeable slider on embla-carousel-react - horizontal or vertical, keyboard and drag support, basis-driven multi-slide views and prev/next controls from the ai2 Button.
"use client"
import { Card, CardContent } from "@/components/ui/card"
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
export default function CarouselDemo() {
return (
<Carousel className="w-full max-w-xs">
<CarouselContent>
{Array.from({ length: 5 }).map((_, i) => (
<CarouselItem key={i}>
<Card variant="soft">
<CardContent className="flex aspect-square items-center justify-center p-6">
<span className="text-4xl font-semibold">{i + 1}</span>
</CardContent>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/carouselDependencies including embla-carousel-react, the @ai2/tokens theme and the component file install together. The @ai2/button component installs alongside - the prev/next controls reuse it.
Install dependencies
npm install embla-carousel-react@^8.6.0 lucide-react@^1.23.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/carousel.tsx"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
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" ? "-ms-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" ? "ps-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}Carousel imports the Button from components/ui/button.tsx for its prev/next controls - copy the Button component as well. Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the tone colors will be missing.
Usage
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
<Carousel className="w-full max-w-xs">
<CarouselContent>
{items.map((item) => (
<CarouselItem key={item.id}>{item.label}</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>Wrap each slide in a CarouselItem inside CarouselContent, then drop in CarouselPrevious and CarouselNext. Set a basis on the item to show more than one slide at a time.
Examples
Basic
Multiple slides per view
Set a basis on each item to fit several slides at once.
Vertical
Props
The controls also forward the ai2 Button props, and CarouselContent and CarouselItem forward the standard div props - set the slide count with the basis utility on CarouselItem.
Carousel
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Scroll axis. Sets the embla axis and switches the prev/next controls between left/right and up/down placement. |
opts | CarouselOptions | undefined | Options passed straight to embla, for example { align: "start" } or { loop: true }. |
plugins | CarouselPlugin | undefined | Embla plugins such as autoplay. Accepts the array embla-carousel-react expects. |
setApi | (api: CarouselApi) => void | undefined | Receives the embla api once ready, so you can read the selected index or drive the carousel from outside. |
CarouselItem
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | undefined | Set the basis here to show more than one slide at a time (for example basis-1/3 for three across). Defaults to basis-full, one slide per view. |
ai2 Carousel: swipeable slider for React, embla under the hood
The ai2 Carousel is a shadcn-compatible slider for React, built on embla-carousel-react and styled with Tailwind CSS v4. It scrolls through a row or column of slides with drag, arrow keys and prev/next buttons, and shows one slide or several at a time depending on the basis you set.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Carousel?
It is a five-part composition: Carousel, CarouselContent, CarouselItem, CarouselPrevious and CarouselNext. The anatomy matches shadcn/ui exactly, so existing snippets and AI agents keep working without changes.
Each CarouselItem is basis-full by default, so one slide fills the view; set a basis such as basis-1/3 to show several at once. Orientation flips the axis to vertical, opts forwards embla options like align and loop, and plugins accepts embla plugins such as autoplay. The prev and next controls are built from the ai2 Button and disable themselves at the ends.
Why use it
- Real embla engine: embla-carousel-react handles drag physics, snapping and momentum, so slides feel natural on touch and mouse without custom scroll math.
- One or many slides: CarouselItem is basis-full by default; set basis-1/2, basis-1/3 and so on to show multiple slides per view from the same markup.
- Keyboard and buttons: Arrow keys move the carousel while it is focused, and the prev/next buttons scroll and disable themselves at the first and last slide.
- Horizontal or vertical: Flip orientation to vertical and the axis, content stacking and control placement all follow, no other changes needed.
- Agent-readable metadata: The registry item describes its parts and intended use in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, embla-carousel-react, the @ai2/button dependency and the @ai2/tokens theme to your project.
- Basis-driven layout: Slide count per view is pure CSS: set the basis on CarouselItem and the carousel snaps to match, so multi-item views need no config.
- Embla options and plugins: opts forwards align, loop, dragFree and the rest of embla's options, and plugins accepts autoplay or any other embla plugin.
- External api access: setApi hands you the embla api once mounted, so you can read the selected index, build dot indicators or scroll programmatically.
- Buttons from ai2 Button: CarouselPrevious and CarouselNext render the ai2 Button as icon buttons, so they inherit your variants, focus ring and tokens.
- Data attributes for styling: Every part exposes data-slot, so you can restyle the track, slides or controls from CSS without forking the component.
Production tips
- Set basis for multi-slide views: Leave CarouselItem at basis-full for a one-at-a-time slider, or set basis-1/2 or basis-1/3 to fit two or three slides per view.
- Use align: start with multi-item: When showing several slides, pass opts={{ align: "start" }} so the row lines up on the left edge instead of centering the active slide.
- Give vertical carousels a height: A vertical carousel needs a fixed height on CarouselContent (for example h-64) so embla knows how far it can scroll.
- Leave room for the controls: Prev and next sit just outside the track, so keep some horizontal (or vertical) padding around the carousel or the buttons get clipped.
- Reach for autoplay via plugins: Do not roll your own timer. Add the embla autoplay plugin through the plugins prop so it pauses on interaction and stays in sync.
Works with the rest of ai2
Carousels frame collections across the registry. Wrap each slide in a ai2 Card for framed content, and the prev/next controls are the ai2 Button under the hood, so they inherit your variants and tokens.
Pair it with a ai2 Badge to tag slides, or an ai2 Avatar row inside a testimonial slide. Everything shares one token source, so combinations stay visually consistent in both modes.