Skip to content

Carousel

Data Display

Manually navigated slides with bounded or looping controls and RTL keyboard support.

Feature slidesCompact looping carouselBounded controlsEmpty state
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

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
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.

Carousel props
PropType / DefaultDescription
labelstring

Default: required

Accessible name for the carousel and its previous/next buttons.
slidesCarouselSlide[]

Default: required

Unique id, label, and ReactNode content for each slide. Only the current slide is mounted.
loopboolean

Default: false

Wrap next and previous actions at either boundary.
defaultIndexnumber

Default: 0

Initial zero-based index; clamped to the available slides.
onIndexChange(index: number) => void

Default: —

Observe navigation; the carousel owns its current index.
classNamestring

Default: —

Extend the component container with theme-aware utility classes.

Build with these too