A swipeable, keyboard-operable carousel on Embla with slide labels, dot pagination, vertical mode, loop, and autoplay that pauses on hover, focus and reduced motion.
Ship faster
Preview every change on its own URL before it reaches customers.
One workspace
Projects, files and approvals live together, with a full history.
See what matters
Usage and performance in one dashboard, updated as it happens.
Secure by default
Roles, audit logs and two-step sign-in are on from day one.
import { BarChart3, Layers, Rocket, ShieldCheck } from "lucide-react";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPlayPause,
CarouselPrevious,
} from "@/components/ballmac/carousel";
const slides = [
{ icon: Rocket, title: "Ship faster", text: "Preview every change on its own URL before it reaches customers.", tone: "bg-chart-1/15 text-chart-1", glow: "from-chart-1/15" },
{ icon: Layers, title: "One workspace", text: "Projects, files and approvals live together, with a full history.", tone: "bg-chart-2/15 text-chart-2", glow: "from-chart-2/15" },
{ icon: BarChart3, title: "See what matters", text: "Usage and performance in one dashboard, updated as it happens.", tone: "bg-chart-3/15 text-chart-3", glow: "from-chart-3/15" },
{ icon: ShieldCheck, title: "Secure by default", text: "Roles, audit logs and two-step sign-in are on from day one.", tone: "bg-chart-4/15 text-chart-4", glow: "from-chart-4/15" },
];
export default function CarouselDemo() {
return (
<Carousel label="Product highlights" autoplay={6000} opts={{ loop: true }} className="w-full max-w-md">
<CarouselContent>
{slides.map(({ icon: Icon, title, text, tone, glow }, i) => (
<CarouselItem key={title}>
<div className={`relative flex h-56 flex-col justify-end gap-3 overflow-hidden rounded-2xl border bg-gradient-to-br ${glow} via-card to-card p-6 shadow-sm`}>
<span aria-hidden="true" className="absolute top-4 end-5 font-mono text-5xl font-semibold text-foreground/[0.06] tabular-nums">
0{i + 1}
</span>
<span className={`flex size-11 items-center justify-center rounded-xl ${tone}`}>
<Icon aria-hidden="true" className="size-5" />
</span>
<div>
<h3 className="text-lg font-semibold tracking-tight">{title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{text}</p>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="-start-3 sm:-start-5" />
<CarouselNext className="-end-3 sm:-end-5" />
<div className="mt-3 flex items-center justify-center gap-2">
<CarouselDots />
<CarouselPlayPause />
</div>
</Carousel>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/carouselInstall the dependencies.
$ pnpm add embla-carousel-react@^8 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presets @ballmac/i18n @ballmac/directionCopy the source into your project.
components/ballmac/carousel.tsx// Ballmac UI: Carousel. https://ui.ballmac.com/components/carousel // Based on shadcn/ui Carousel (MIT, Copyright (c) 2023 shadcn) on Embla Carousel (MIT, Copyright (c) 2019 David Jerleke), adding slide labels, dot pagination, autoplay with a pause control, and reduced-motion handling. "use client"; import * as React from "react"; import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"; import { ArrowLeft, ArrowRight, Pause, Play } from "lucide-react"; import { cn } from "@/lib/utils"; import { useReducedMotionSafe } from "@/lib/ballmac/motion"; import { useDirection } from "@/lib/ballmac/direction" import { useMessages } from "@/lib/ballmac/i18n"; type CarouselApi = UseEmblaCarouselType[1]; type CarouselOptions = NonNullable<Parameters<typeof useEmblaCarousel>[0]>; type CarouselContextValue = { viewportRef: UseEmblaCarouselType[0]; api: CarouselApi; orientation: "horizontal" | "vertical"; scrollPrev: () => void; scrollNext: () => void; scrollTo: (index: number) => void; canScrollPrev: boolean; canScrollNext: boolean; selectedIndex: number; count: number; playing: boolean; autoplay: boolean; setPlaying: (playing: boolean) => void; }; const CarouselContext = React.createContext<CarouselContextValue | null>(null); const SlideContext = React.createContext<{ index: number; count: number } | null>(null); function useCarousel() { const context = React.useContext(CarouselContext); if (!context) throw new Error("useCarousel must be used within <Carousel>"); return context; } type CarouselProps = Omit<React.ComponentProps<"div">, "children"> & { children?: React.ReactNode; /** Embla options, for example `{ loop: true, align: "start" }`. */ opts?: CarouselOptions; /** Direction of travel. */ orientation?: "horizontal" | "vertical"; /** Receives the Embla API once it is ready, for custom controls. */ setApi?: (api: NonNullable<CarouselApi>) => void; /** Advance on a timer, in milliseconds (`true` = 5000). Pauses on hover and focus, and never runs under reduced motion. Pair it with `CarouselPlayPause`. */ autoplay?: boolean | number; /** Accessible name of the carousel region. */ label?: string; }; /** A swipeable, keyboard-operable slider of slides. Compose with CarouselContent, CarouselItem and the controls. */ function Carousel({ orientation = "horizontal", opts, setApi, autoplay = false, label, className, children, onKeyDownCapture, onMouseEnter, onMouseLeave, onFocus, onBlur, ...props }: CarouselProps) { const msg = useMessages() label ??= msg("carousel.label", "Carousel") const reduce = useReducedMotionSafe(); const dir = useDirection(); const [viewportRef, api] = useEmblaCarousel( { ...opts, axis: orientation === "horizontal" ? "x" : "y", direction: dir, ...(reduce ? { duration: 0 } : {}) }, ); const [canScrollPrev, setCanScrollPrev] = React.useState(false); const [canScrollNext, setCanScrollNext] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const [count, setCount] = React.useState(0); const [userPlaying, setUserPlaying] = React.useState(true); const [hovered, setHovered] = React.useState(false); const [focused, setFocused] = React.useState(false); React.useEffect(() => { if (!api) return; const update = () => { setCanScrollPrev(api.canScrollPrev()); setCanScrollNext(api.canScrollNext()); setSelectedIndex(api.selectedScrollSnap()); setCount(api.scrollSnapList().length); }; update(); api.on("select", update).on("reInit", update); setApi?.(api); return () => { api.off("select", update).off("reInit", update); }; }, [api, setApi]); const autoplayMs = autoplay === true ? 5000 : autoplay || 0; const running = !!autoplayMs && !reduce && userPlaying && !hovered && !focused; React.useEffect(() => { if (!api || !running) return; const id = window.setInterval(() => { if (document.hidden) return; if (api.canScrollNext()) api.scrollNext(); else api.scrollTo(0); }, autoplayMs); return () => window.clearInterval(id); }, [api, running, autoplayMs]); const value: CarouselContextValue = { viewportRef, api, orientation, scrollPrev: () => api?.scrollPrev(), scrollNext: () => api?.scrollNext(), scrollTo: (i) => api?.scrollTo(i), canScrollPrev, canScrollNext, selectedIndex, count, playing: !!autoplayMs && !reduce && userPlaying, autoplay: !!autoplayMs && !reduce, setPlaying: setUserPlaying, }; return ( <CarouselContext.Provider value={value}> <div data-slot="carousel" role="region" aria-roledescription="carousel" aria-label={label} className={cn("relative min-w-0", className)} onKeyDownCapture={(event) => { onKeyDownCapture?.(event); if (event.defaultPrevented) return; const target = event.target as HTMLElement; if (target.closest("input, textarea, select, [contenteditable=true]")) return; const prev = orientation === "horizontal" ? (dir === "rtl" ? "ArrowRight" : "ArrowLeft") : "ArrowUp"; const next = orientation === "horizontal" ? (dir === "rtl" ? "ArrowLeft" : "ArrowRight") : "ArrowDown"; if (event.key === prev) { event.preventDefault(); api?.scrollPrev(); } else if (event.key === next) { event.preventDefault(); api?.scrollNext(); } }} onMouseEnter={(e) => { onMouseEnter?.(e); setHovered(true); }} onMouseLeave={(e) => { onMouseLeave?.(e); setHovered(false); }} onFocus={(e) => { onFocus?.(e); setFocused(true); }} onBlur={(e) => { onBlur?.(e); setFocused(false); }} {...props} > {children} </div> </CarouselContext.Provider> ); } type CarouselContentProps = React.ComponentProps<"div"> & { /** Classes for the clipping viewport around the track. */ viewportClassName?: string; }; function CarouselContent({ className, viewportClassName, children, ...props }: CarouselContentProps) { const { viewportRef, orientation, autoplay, playing } = useCarousel(); const slides = React.Children.toArray(children); return ( <div ref={viewportRef} data-slot="carousel-viewport" className={cn("overflow-hidden", viewportClassName)} > <div data-slot="carousel-content" aria-live={autoplay && playing ? "off" : "polite"} className={cn("flex", orientation === "horizontal" ? "-ms-4" : "-mt-4 flex-col", className)} {...props} > {slides.map((child, index) => ( <SlideContext.Provider key={index} value={{ index, count: slides.length }}> {child} </SlideContext.Provider> ))} </div> </div> ); } type CarouselItemProps = React.ComponentProps<"div">; function CarouselItem({ className, ...props }: CarouselItemProps) { const { orientation } = useCarousel(); const slide = React.useContext(SlideContext); return ( <div role="group" aria-roledescription="slide" aria-label={slide ? `${slide.index + 1} of ${slide.count}` : undefined} data-slot="carousel-item" className={cn( "min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "ps-4" : "pt-4", className, )} {...props} /> ); } const controlClass = "inline-flex size-9 items-center justify-center rounded-full border bg-background/90 text-foreground shadow-sm outline-none backdrop-blur transition-[background-color,opacity,box-shadow] hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4"; type CarouselPreviousProps = React.ComponentProps<"button">; function CarouselPrevious({ className, ...props }: CarouselPreviousProps) { const msg = useMessages() const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( <button type="button" data-slot="carousel-previous" aria-label={msg("carousel.previousSlide", "Previous slide")} disabled={!canScrollPrev} onClick={scrollPrev} className={cn( controlClass, "absolute z-10", orientation === "horizontal" ? "top-1/2 start-3 -translate-y-1/2" : "top-3 left-1/2 -translate-x-1/2 rotate-90", className, )} {...props} > <ArrowLeft aria-hidden="true" className={orientation === "horizontal" ? "rtl:rotate-180" : undefined} /> </button> ); } type CarouselNextProps = React.ComponentProps<"button">; function CarouselNext({ className, ...props }: CarouselNextProps) { const msg = useMessages() const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( <button type="button" data-slot="carousel-next" aria-label={msg("carousel.nextSlide", "Next slide")} disabled={!canScrollNext} onClick={scrollNext} className={cn( controlClass, "absolute z-10", orientation === "horizontal" ? "top-1/2 end-3 -translate-y-1/2" : "bottom-3 left-1/2 -translate-x-1/2 rotate-90", className, )} {...props} > <ArrowRight aria-hidden="true" className={orientation === "horizontal" ? "rtl:rotate-180" : undefined} /> </button> ); } type CarouselDotsProps = React.ComponentProps<"div">; /** Pagination dots. The active dot stretches into a pill. Each dot is a labelled button. */ function CarouselDots({ className, ...props }: CarouselDotsProps) { const msg = useMessages() const { count, selectedIndex, scrollTo } = useCarousel(); if (count < 2) return null; return ( <div data-slot="carousel-dots" role="group" aria-label={msg("carousel.chooseSlide", "Choose slide")} className={cn("flex items-center justify-center gap-1", className)} {...props} > {Array.from({ length: count }, (_, i) => ( <button key={i} type="button" aria-label={msg("carousel.goToSlide", "Go to slide {n}", { n: i + 1 })} aria-current={i === selectedIndex ? "true" : undefined} onClick={() => scrollTo(i)} className="group/dot inline-flex h-6 min-w-6 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <span className={cn( "block h-1.5 rounded-full transition-[width,background-color] duration-300 motion-reduce:transition-none", i === selectedIndex ? "w-5 bg-primary" : "w-1.5 bg-muted-foreground/40 group-hover/dot:bg-muted-foreground/70", )} /> </button> ))} </div> ); } type CarouselPlayPauseProps = React.ComponentProps<"button">; /** Pause and resume autoplay. Renders nothing when `autoplay` is off or reduced motion is on. */ function CarouselPlayPause({ className, ...props }: CarouselPlayPauseProps) { const msg = useMessages() const { autoplay, playing, setPlaying } = useCarousel(); if (!autoplay) return null; return ( <button type="button" data-slot="carousel-play-pause" aria-label={playing ? msg("carousel.pauseAutoplay", "Pause autoplay") : msg("carousel.startAutoplay", "Start autoplay")} onClick={() => setPlaying(!playing)} className={cn(controlClass, "size-8", className)} {...props} > {playing ? <Pause aria-hidden="true" /> : <Play aria-hidden="true" />} </button> ); } export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, CarouselDots, CarouselPlayPause, useCarousel, type CarouselApi, type CarouselProps, type CarouselContentProps, type CarouselItemProps, type CarouselPreviousProps, type CarouselNextProps, type CarouselDotsProps, type CarouselPlayPauseProps, };Update the import paths to match your project setup.
Usage
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, CarouselDots, CarouselPlayPause, useCarousel } from "@/components/ballmac/carousel"The full example is in the Code tab above.
Examples
Multiple and vertical
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ballmac/carousel";
const items = ["Design", "Build", "Review", "Launch", "Measure", "Learn"];
export default function CarouselStates() {
return (
<div className="grid w-full max-w-md gap-8">
<Carousel label="Workflow steps" opts={{ align: "start" }} className="px-6">
<CarouselContent>
{items.map((t, i) => (
<CarouselItem key={t} className="basis-1/2 sm:basis-1/3">
<div className="flex aspect-square flex-col items-center justify-center rounded-xl border bg-card text-sm">
<span className="text-2xl font-semibold tabular-nums">{i + 1}</span>
{t}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="-start-1 size-8" />
<CarouselNext className="-end-1 size-8" />
</Carousel>
<Carousel label="Vertical tips" orientation="vertical" className="mx-auto w-56 py-2">
<CarouselContent viewportClassName="h-28">
{["Press ⌘K to search", "Drag to reorder", "Hold Shift to select a range"].map((t) => (
<CarouselItem key={t} className="pt-2">
<div className="flex h-24 items-center justify-center rounded-xl border bg-card px-4 text-center text-sm">{t}</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="-top-3 size-7" />
<CarouselNext className="-bottom-3 size-7" />
</Carousel>
</div>
);
}API reference
<Carousel>
| Prop | Type | Default |
|---|---|---|
children | React.ReactNode | — |
optsEmbla options, for example `{ loop: true, align: "start" }`. | CarouselOptions | — |
orientationDirection of travel. | "horizontal" | "vertical" | "horizontal" |
setApiReceives the Embla API once it is ready, for custom controls. | (api: NonNullable<CarouselApi>) => void | — |
autoplayAdvance on a timer, in milliseconds (`true` = 5000). Pauses on hover and focus, and never runs under reduced motion. Pair it with `CarouselPlayPause`. | boolean | number | false |
labelAccessible name of the carousel region. | string | — |
<CarouselContent>
| Prop | Type | Default |
|---|---|---|
viewportClassNameClasses for the clipping viewport around the track. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| ArrowLeft / ArrowRight | Previous or next slide (Up / Down when vertical) |
| Tab | Reaches arrows, dots and the pause button |
| Swipe / drag | Moves slides with touch or mouse |
Use with AI
Compose Carousel, CarouselContent and CarouselItem with CarouselPrevious, CarouselNext, CarouselDots and CarouselPlayPause. Give Carousel a label. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Carousel (@ballmac/carousel) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Feature highlights and testimonials
- Product image galleries
- Horizontal lists that overflow
Not for
- Content people must read in full; show it all
- A marquee of logos; use marquee
Registry JSON: https://ui.ballmac.com/r/carousel.json
Credits
Based on shadcn/ui Carousel, adapted by Ballmac. Free to use in personal and commercial projects.
Pairs well with
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Audio Player
A podcast-style audio player: play and pause, skip back and forward, a scrubber with chapter ticks, a named current chapter and a speed control. Works with a real file or as a silent demo.