Skip to content
UI

Carousel

A row of slides that the user moves through with buttons, swipes or arrow keys.

@zenoxai/ui/components/carousel Markdown
18Phishing kits
42Lookalike domains
7Leaked credentials
23Exposed services
5Fake social profiles
function CarouselDemo() {
  const threats = [
    { name: "Phishing kits", count: 18 },
    { name: "Lookalike domains", count: 42 },
    { name: "Leaked credentials", count: 7 },
    { name: "Exposed services", count: 23 },
    { name: "Fake social profiles", count: 5 },
  ]

  return (
    <Carousel aria-label="Threats this week" className="w-full max-w-xs">
      <CarouselContent>
        {threats.map((threat, index) => (
          <CarouselItem
            key={threat.name}
            aria-label={`${index + 1} of ${threats.length}`}
          >
            <Card>
              <CardContent className="flex aspect-video flex-col items-center justify-center gap-1">
                <span className="text-xl font-semibold text-text-primary">
                  {threat.count}
                </span>
                <span className="text-sm text-text-secondary">
                  {threat.name}
                </span>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Usage

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@zenoxai/ui/components/carousel"
<Carousel aria-label="Threats this week">
  <CarouselContent>
    <CarouselItem aria-label="1 of 3">...</CarouselItem>
    <CarouselItem aria-label="2 of 3">...</CarouselItem>
    <CarouselItem aria-label="3 of 3">...</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Use a carousel when items are browsed one or a few at a time and their order matters little: evidence screenshots, report templates, onboarding cards. It hides most of its content, so do not put information there that the user must see. For a list the user scans or compares, use a grid or a Scroll Area.

The component wraps Embla Carousel. Pass Embla options through opts and plugins (autoplay, wheel gestures) through plugins. From the md breakpoint (768px), CarouselPrevious and CarouselNext sit outside the slides, 48px to each side: leave that room around the carousel, or position them yourself with className. On smaller screens they move inside the edges, 8px in, over the slides, so they stay on screen and within reach; change that placement with max-md: classes.

Examples

Several slides per view

Set the width of each slide with a basis-* class on CarouselItem. With opts={{ align: "start" }} the slides align to the start of the row instead of the center.

Weekly exposure
Monthly exposure
Brand abuse
Takedown status
Executive summary
Vulnerability trends
function CarouselMultiple() {
  const reports = [
    "Weekly exposure",
    "Monthly exposure",
    "Brand abuse",
    "Takedown status",
    "Executive summary",
    "Vulnerability trends",
  ]

  return (
    <Carousel
      aria-label="Report templates"
      opts={{ align: "start" }}
      className="w-full max-w-md"
    >
      <CarouselContent>
        {reports.map((report, index) => (
          <CarouselItem
            key={report}
            aria-label={`${index + 1} of ${reports.length}`}
            className="basis-1/2 sm:basis-1/3"
          >
            <Card>
              <CardContent className="flex aspect-square items-center justify-center text-center text-sm font-medium text-text-primary">
                {report}
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Reading the position

setApi hands you the Embla API. Subscribe to its select event to show the current slide or to drive your own controls.

Captured 21 Oct, 09:14
Captured 21 Oct, 15:40
Captured 22 Oct, 08:02
Captured 23 Oct, 11:27
Screenshot 1 of 4
function CarouselWithApi() {
  const [api, setApi] = React.useState<CarouselApi>()
  const [current, setCurrent] = React.useState(0)
  const captures = [
    "21 Oct, 09:14",
    "21 Oct, 15:40",
    "22 Oct, 08:02",
    "23 Oct, 11:27",
  ]

  React.useEffect(() => {
    if (!api) return
    const onSelect = () => setCurrent(api.selectedScrollSnap())
    api.on("select", onSelect)
    return () => {
      api.off("select", onSelect)
    }
  }, [api])

  return (
    <div className="flex w-full max-w-xs flex-col items-center gap-2">
      <Carousel
        aria-label="Screenshots of acme-login.co"
        setApi={setApi}
        className="w-full"
      >
        <CarouselContent>
          {captures.map((capture, index) => (
            <CarouselItem
              key={capture}
              aria-label={`${index + 1} of ${captures.length}`}
            >
              <Card>
                <CardContent className="flex aspect-video items-center justify-center text-sm text-text-secondary">
                  Captured {capture}
                </CardContent>
              </Card>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
      <span className="text-sm text-text-muted" aria-live="polite">
        Screenshot {current + 1} of {captures.length}
      </span>
    </div>
  )
}

Accessibility

  • Carousel is a region with aria-roledescription="carousel", and each CarouselItem a group with aria-roledescription="slide". Give the carousel an aria-label and each slide a position label such as “2 of 5”.
  • Left and Right arrow keys move between slides while focus is inside the carousel. The buttons are disabled at either end, unless opts={{ loop: true }}.
  • The buttons carry the English labels “Previous slide” and “Next slide”. Pass a translated aria-label when the interface is not in English.
  • Do not autoplay. If a plugin does, give the user a control to pause it.

API reference

Generated from the TypeScript source. className, style and children are accepted everywhere and omitted below.

Carousel shows a row of slides one view at a time, moved with the arrow buttons, a swipe or the arrow keys; it is built on Embla. Use it for media and previews, not for content the user must read in full: hidden slides are easy to miss.

PropTypeDefault
optsPartial<OptionsType>

Embla options, such as `loop` or `align`; `axis` follows `orientation`.

-
orientation"horizontal" | "vertical"

Scroll direction. Defaults to horizontal.

horizontal
pluginsCreatePluginType<LoosePluginType, {}>[]

Embla plugins, such as autoplay.

-
setApi((api: EmblaCarouselType) => void)

Receives the Embla API when ready, to drive the carousel from outside.

-

CarouselContent

CarouselContent is the scrolling track that holds the CarouselItems.

No props of its own. Accepts the props of the element it renders.

CarouselItem

CarouselItem is one slide. It spans the whole track by default; set a basis class such as `basis-1/3` to show several at once.

No props of its own. Accepts the props of the element it renders.

CarouselPrevious

CarouselPrevious scrolls back one slide and is disabled at the start. Its accessible name is the English "Previous slide"; pass a translated aria-label.

PropTypeDefault
focusableWhenDisabledboolean

Whether the button should be focusable when disabled.

false
nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).

true
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component's HTML element with a different tag, or compose it with another component.

-
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null

Height: `xs` 24px, `sm` 28px, `default` 32px, `lg` 36px. The `icon-*` sizes are square buttons of the same heights for a single icon. The icon follows the size name: 12px at `xs`, 14px at `sm`, 16px at `default` and `lg`.

icon-sm
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null

Visual weight. `default` is the filled primary action, one per view; `outline` and `secondary` are for the other actions; `ghost` has no fill until hover, for toolbars and icon buttons; `destructive` is for delete and other irreversible actions; `link` looks like inline text.

outline

CarouselNext

CarouselNext scrolls forward one slide and is disabled at the end. Its accessible name is the English "Next slide"; pass a translated aria-label.

PropTypeDefault
focusableWhenDisabledboolean

Whether the button should be focusable when disabled.

false
nativeButtonboolean

Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).

true
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ButtonState>

Allows you to replace the component's HTML element with a different tag, or compose it with another component.

-
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null

Height: `xs` 24px, `sm` 28px, `default` 32px, `lg` 36px. The `icon-*` sizes are square buttons of the same heights for a single icon. The icon follows the size name: 12px at `xs`, 14px at `sm`, 16px at `default` and `lg`.

icon-sm
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null

Visual weight. `default` is the filled primary action, one per view; `outline` and `secondary` are for the other actions; `ghost` has no fill until hover, for toolbars and icon buttons; `destructive` is for delete and other irreversible actions; `link` looks like inline text.

outline

useCarousel

useCarousel returns the Embla API and scroll state of the Carousel above.

No props of its own. Accepts the props of the element it renders.