Use Carousel for a short sequence that benefits from horizontal or vertical browsing. Do not hide essential information exclusively in later slides. Leave room for navigation buttons positioned outside the track.
import { Card, CardContent } from "@/components/ui/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
const slides = [
{
eyebrow: "Template",
title: "Portfolio",
detail: "A project-focused landing page with clear case-study navigation.",
className: "bg-main text-main-foreground",
},
{
eyebrow: "Component",
title: "Command menu",
detail: "Searchable actions with keyboard navigation and honest local feedback.",
className: "bg-secondary-background text-foreground",
},
{
eyebrow: "Theme",
title: "Mono preset",
detail: "Hard shadows, strong borders, and a complete light and dark token pair.",
className: "bg-main text-main-foreground",
},
];
export default function CarouselDemo() {
return (
<div className="w-full max-w-md px-12">
<Carousel className="w-full" aria-label="Featured registry examples">
<CarouselContent>
{slides.map((slide) => (
<CarouselItem key={slide.title}>
<div className="p-1">
<Card className={`h-full overflow-hidden p-0 shadow-none ${slide.className}`}>
<CardContent className="flex aspect-[4/3] flex-col items-start justify-between gap-6 p-5">
<span className="rounded-base border-2 border-current px-2 py-1 text-xs font-heading uppercase tracking-wide">
{slide.eyebrow}
</span>
<div>
<h3 className="text-2xl font-heading">{slide.title}</h3>
<p className="mt-2 text-sm leading-6">{slide.detail}</p>
</div>
</CardContent>
</Card>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}
#Installation
Complete the base setup. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/carousel.jsonnpm install @base-ui/react@^1.8.0 embla-carousel-react@^8.6.0Also install Button, then create components/ui/carousel.tsx:
"use client";
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-react";
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,
onKeyDown,
...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>) => {
const target = event.target as HTMLElement;
if (
event.defaultPrevented ||
target.closest('[data-slot="carousel"]') !== event.currentTarget ||
(target !== event.currentTarget &&
!target.matches('[data-slot="carousel-previous"], [data-slot="carousel-next"]'))
)
return;
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("reInit", onSelect);
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
onKeyDown={(event) => {
onKeyDown?.(event);
handleKeyDown(event);
}}
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 = "noShadow",
size = "icon",
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={(state) =>
cn(
"absolute size-8 rounded-base",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
typeof className === "function" ? className(state) : className,
)
}
disabled={!canScrollPrev}
{...props}
onClick={
mergeProps<"button">(
{
onClick: (event) => {
if (!event.defaultPrevented) scrollPrev();
},
},
{ onClick },
).onClick
}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "noShadow",
size = "icon",
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={(state) =>
cn(
"absolute h-8 w-8 rounded-base",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
typeof className === "function" ? className(state) : className,
)
}
disabled={!canScrollNext}
{...props}
onClick={
mergeProps<"button">(
{
onClick: (event) => {
if (!event.defaultPrevented) scrollNext();
},
},
{ onClick },
).onClick
}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
};
The live preview additionally uses Card. Install it before copying the Code example:
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/card.json#Usage
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
export default function Highlights() {
return (
<div className="px-12">
<Carousel className="w-full max-w-sm" aria-label="Project highlights">
<CarouselContent>
{["Plan", "Build", "Review"].map((title) => (
<CarouselItem key={title}>
<div className="border-2 border-border p-8">{title}</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}#API reference
| Prop / part | Purpose | Default |
|---|---|---|
orientation | horizontal or vertical | horizontal |
opts | Embla options such as align and loop | Embla defaults |
plugins | Explicitly installed Embla plugins | None |
setApi | Receives the Embla API | — |
CarouselContent / CarouselItem | Track and slides | — |
CarouselPrevious / CarouselNext | Boundary-aware navigation buttons | Icon buttons |
CarouselPrevious.variant / size | Previous button appearance and size | noShadow / icon |
CarouselNext.variant / size | Next button appearance and size | noShadow / icon |
#Accessibility
Name the carousel region and provide meaningful slide content. Keep navigation buttons available for people who cannot swipe. Autoplay is not included; adding it requires pause controls and reduced-motion support. Give a vertical carousel a definite height and test the layout on narrow screens.
Left and right arrow shortcuts apply when the carousel region or its navigation buttons have focus. Add tabIndex={0} to make the region focusable. Inputs and other controls inside a slide keep their own keyboard behavior.
Navigation buttons accept state-based className functions. Their onClick handlers run before scrolling; call event.preventDefault() to cancel that button's navigation.
#Slides with inputs
Also install Input, Label, and Slider for this example.
import { useId } from "react";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
export default function CarouselWithInputs() {
const titleId = useId();
return (
<div className="w-full max-w-md px-12">
<Carousel aria-label="Project settings" tabIndex={0} className="w-full">
<CarouselContent>
<CarouselItem>
<div className="grid gap-4 rounded-base border-2 border-border bg-secondary-background p-4">
<Label htmlFor={titleId}>Project title</Label>
<Input id={titleId} defaultValue="Project" />
<span className="text-sm font-heading">Progress</span>
<Slider defaultValue={[50]} getAriaLabel={() => "Project progress"} />
</div>
</CarouselItem>
<CarouselItem>
<div className="rounded-base border-2 border-border bg-secondary-background p-4">
Review the project settings before sharing them with your team.
</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}