Carousel
Data DisplayManually navigated slides with bounded or looping controls and RTL keyboard support.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { Carousel } from "@/components/ui/carousel";
import { Layers3, Sparkles, ShieldCheck } from "lucide-react";
export default function CarouselExample() {
const slides = [
{
id: "build",
label: "Build with confidence",
content: (
<div className="space-y-5 bg-primary/5 p-8 sm:p-12">
<Layers3 className="size-10 text-primary" />
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
01 / Foundation
</p>
<h3 className="text-2xl font-semibold tracking-tight">
Build with confidence
</h3>
<p className="max-w-sm text-sm leading-6 text-muted-foreground">
A considered foundation for your next dashboard, from the first
screen to the last detail.
</p>
</div>
),
},
{
id: "design",
label: "Designed for real work",
content: (
<div className="space-y-5 bg-muted/40 p-8 sm:p-12">
<Sparkles className="size-10 text-primary" />
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
02 / Craft
</p>
<h3 className="text-2xl font-semibold tracking-tight">
Designed for real work
</h3>
<p className="max-w-sm text-sm leading-6 text-muted-foreground">
Thoughtful interactions, clear states, and components that fit
together naturally.
</p>
</div>
),
},
{
id: "ship",
label: "Ready to make it yours",
content: (
<div className="space-y-5 bg-primary/5 p-8 sm:p-12">
<ShieldCheck className="size-10 text-primary" />
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
03 / Launch
</p>
<h3 className="text-2xl font-semibold tracking-tight">
Ready to make it yours
</h3>
<p className="max-w-sm text-sm leading-6 text-muted-foreground">
Editable source, responsive layouts, and accessible controls. Start
with the details already in place.
</p>
</div>
),
},
];
return (
<div className="w-full max-w-xl space-y-8">
<Carousel label="Apex highlights" slides={slides} />
<div>
<p className="mb-3 text-xs font-semibold text-muted-foreground">
COMPACT · LOOPING
</p>
<Carousel
label="Release notes"
loop
slides={[
{
id: "one",
label: "New components",
content: (
<p className="p-6 text-sm">
12 new components. One consistent design language.
</p>
),
},
{
id: "two",
label: "Editable source",
content: (
<p className="p-6 text-sm">
Every component ships with editable source in your starter.
</p>
),
},
]}
/>
</div>
</div>
);
}Usage
Use short, uniquely identified slides for product highlights or release notes. Navigation is manual, so visitors control when content changes. Focus the slide frame to use arrow keys, Home, and End. Use loop for cyclic browsing; the default stops at each end.
import { Carousel } from "@/components/ui/carousel";Shipped as editable source in src/components/ui/carousel.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/carousel.tsx
"use client";
import { useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { useDocumentDirection } from "@/lib/use-document-direction";
export interface CarouselSlide {
id: string;
label: string;
content: ReactNode;
}
export interface CarouselProps {
label: string;
slides: CarouselSlide[];
loop?: boolean;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
className?: string;
}
export function Carousel({
label,
slides,
loop = false,
defaultIndex = 0,
onIndexChange,
className,
}: CarouselProps) {
const [index, setIndex] = useState(defaultIndex);
const rtl = useDocumentDirection() === "rtl";
const current = Math.max(
0,
Math.min(slides.length - 1, Math.floor(index) || 0),
);
const change = (next: number) => {
if (!slides.length) return;
const bounded = loop
? (next + slides.length) % slides.length
: Math.max(0, Math.min(slides.length - 1, next));
setIndex(bounded);
onIndexChange?.(bounded);
};
if (!slides.length)
return (
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
No slides available.
</p>
);
return (
<section
aria-label={label}
aria-roledescription="carousel"
className={cn("min-w-0 space-y-4", className)}
>
<div
tabIndex={0}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault();
change(current + ((event.key === "ArrowRight") !== rtl ? 1 : -1));
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
change(event.key === "Home" ? 0 : slides.length - 1);
}
}}
className="overflow-hidden rounded-xl border bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div
role="group"
aria-roledescription="slide"
aria-label={`${current + 1} of ${slides.length}: ${slides[current].label}`}
key={slides[current].id}
>
{slides[current].content}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-1.5">
{slides.map((slide, position) => (
<button
key={slide.id}
type="button"
aria-label={`Go to slide ${position + 1}: ${slide.label}`}
aria-current={position === current ? "true" : undefined}
onClick={() => change(position)}
className="flex size-7 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-ring"
>
<span
className={cn(
"h-1.5 rounded-full transition-all motion-reduce:transition-none",
position === current
? "w-6 bg-primary"
: "w-1.5 bg-muted-foreground/50",
)}
/>
</button>
))}
</div>
<div className="flex items-center gap-3">
<span
role="status"
aria-live="polite"
className="text-xs text-muted-foreground"
>
{current + 1} / {slides.length}
</span>
<Button
type="button"
size="icon"
variant="outline"
aria-label={`Previous slide in ${label}`}
disabled={slides.length < 2 || (!loop && current === 0)}
onClick={() => change(current - 1)}
>
<ChevronLeft className="size-4 rtl:rotate-180" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
aria-label={`Next slide in ${label}`}
disabled={
slides.length < 2 || (!loop && current === slides.length - 1)
}
onClick={() => change(current + 1)}
>
<ChevronRight className="size-4 rtl:rotate-180" />
</Button>
</div>
</div>
</section>
);
}Required helper: src/lib/use-document-direction.ts
Copy this helper alongside the component. It keeps keyboard navigation aligned with the page direction and supports live RTL changes.
"use client";
import { useSyncExternalStore } from "react";
function subscribe(notify: () => void) {
const observer = new MutationObserver(notify);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
});
return () => observer.disconnect();
}
function snapshot(): "ltr" | "rtl" {
return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
}
/** Keep headless controls aligned with the document, including live RTL changes. */
export function useDocumentDirection() {
return useSyncExternalStore(subscribe, snapshot, () => "ltr" as const);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Accessible name for the carousel and its previous/next buttons. |
| slides | CarouselSlide[]Default: required | Unique id, label, and ReactNode content for each slide. Only the current slide is mounted. |
| loop | booleanDefault: false | Wrap next and previous actions at either boundary. |
| defaultIndex | numberDefault: 0 | Initial zero-based index; clamped to the available slides. |
| onIndexChange | (index: number) => voidDefault: — | Observe navigation; the carousel owns its current index. |
| className | stringDefault: — | Extend the component container with theme-aware utility classes. |