---
title: Carousel
description: "A slide carousel with previous/next buttons and dots, horizontal or vertical."
sidebar:
  badge: New
---

```tsx
import * as stylex from '@stylexjs/stylex';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight } from '@/lib/constants.stylex';

export default function CarouselDemo() {
  return (
    <Carousel style={styles.carousel}>
      <CarouselContent>
        {Array.from({ length: 5 }, (_, index) => (
          <CarouselItem key={index}>
            <Card style={styles.slide}>
              <span {...stylex.props(styles.number)}>{index + 1}</span>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  );
}

const styles = stylex.create({
  carousel: {
    maxWidth: container.md,
    width: '100%',
  },
  slide: {
    alignItems: 'center',
    aspectRatio: '1',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
});
```

## Install

```bash
npx @madeui/cli add carousel
```

Installs `embla-carousel-react` alongside the component.

## Usage

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
```

```tsx
<Carousel>
  <CarouselContent>
    <CarouselItem>…</CarouselItem>
    <CarouselItem>…</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
```

## Composition

```tsx
<Carousel>
  <CarouselContent>
    <CarouselItem />
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
  <CarouselDots />
</Carousel>
```

## Sizes

Set `flexBasis` on `CarouselItem` through `style` to show several slides per view.

```tsx
import * as stylex from '@stylexjs/stylex';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight } from '@/lib/constants.stylex';

export default function CarouselSizes() {
  return (
    <Carousel style={styles.carousel}>
      <CarouselContent>
        {Array.from({ length: 6 }, (_, index) => (
          <CarouselItem key={index} style={styles.item}>
            <Card style={styles.slide}>
              <span {...stylex.props(styles.number)}>{index + 1}</span>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  );
}

const styles = stylex.create({
  carousel: {
    maxWidth: container.lg,
    width: '100%',
  },
  // Three slides per view: each item takes a third of the track.
  item: {
    flexBasis: '33.333%',
  },
  slide: {
    alignItems: 'center',
    aspectRatio: '1',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
});
```

## Spacing

The default gap is a negative margin on `CarouselContent` paid back as padding on each `CarouselItem`. Override both together: `marginLeft` and `paddingLeft` for horizontal carousels, `marginTop` and `paddingTop` for vertical ones.

```tsx
import * as stylex from '@stylexjs/stylex';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight, space } from '@/lib/constants.stylex';

export default function CarouselSpacing() {
  return (
    <Carousel style={styles.carousel}>
      <CarouselContent style={styles.content}>
        {Array.from({ length: 6 }, (_, index) => (
          <CarouselItem key={index} style={styles.item}>
            <Card style={styles.slide}>
              <span {...stylex.props(styles.number)}>{index + 1}</span>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  );
}

const styles = stylex.create({
  carousel: {
    maxWidth: container.lg,
    width: '100%',
  },
  // A tighter gap: the track's negative margin and each item's padding
  // must move together.
  content: {
    marginLeft: `calc(-1 * ${space.s1})`,
  },
  item: {
    flexBasis: '33.333%',
    paddingLeft: space.s1,
  },
  slide: {
    alignItems: 'center',
    aspectRatio: '1',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
});
```

## Orientation

Vertical carousels need a fixed `height` on `CarouselContent`; ArrowUp/ArrowDown replace ArrowLeft/ArrowRight.

```tsx
import * as stylex from '@stylexjs/stylex';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight, space } from '@/lib/constants.stylex';

export default function CarouselOrientation() {
  return (
    <Carousel orientation="vertical" style={styles.carousel}>
      <CarouselContent style={styles.content}>
        {Array.from({ length: 5 }, (_, index) => (
          <CarouselItem key={index}>
            <Card style={styles.slide}>
              <span {...stylex.props(styles.number)}>{index + 1}</span>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  );
}

const styles = stylex.create({
  // Room for the previous/next buttons, which sit above and below the track
  // from 640px up. Narrower than that they overlay the track and need none.
  carousel: {
    marginBlock: { default: 0, '@media (min-width: 640px)': space.s12 },
    maxWidth: container.md,
    width: '100%',
  },
  // A vertical track needs a fixed height; slides fill it.
  content: {
    height: container.xs,
  },
  slide: {
    alignItems: 'center',
    height: '100%',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
});
```

## Dots

```tsx
import * as stylex from '@stylexjs/stylex';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight } from '@/lib/constants.stylex';

export default function CarouselDotsExample() {
  return (
    <Carousel style={styles.carousel}>
      <CarouselContent>
        {Array.from({ length: 5 }, (_, index) => (
          <CarouselItem key={index}>
            <Card style={styles.slide}>
              <span {...stylex.props(styles.number)}>{index + 1}</span>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselDots />
    </Carousel>
  );
}

const styles = stylex.create({
  carousel: {
    maxWidth: container.md,
    width: '100%',
  },
  slide: {
    alignItems: 'center',
    aspectRatio: '1',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
});
```

## API

Pass `setApi` to get the Embla instance and subscribe to its events.

```tsx
'use client';

import * as React from 'react';

import * as stylex from '@stylexjs/stylex';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  type CarouselApi,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight, space } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';

const SLIDES = [1, 2, 3, 4, 5];

export default function CarouselApiExample() {
  const [api, setApi] = React.useState<CarouselApi>();
  // Seeded from the slide list so the first paint reads "Slide 1 of 5"
  // instead of "Slide 0 of 0"; Embla corrects both once it has measured.
  const [current, setCurrent] = React.useState(1);
  const [count, setCount] = React.useState(SLIDES.length);

  React.useEffect(() => {
    if (!api) return;
    const update = () => {
      setCount(api.scrollSnapList().length);
      setCurrent(api.selectedScrollSnap() + 1);
    };
    update();
    api.on('select', update);
    api.on('reInit', update);
    return () => {
      api.off('select', update);
      api.off('reInit', update);
    };
  }, [api]);

  return (
    <div {...stylex.props(styles.root)}>
      <Carousel setApi={setApi}>
        <CarouselContent>
          {SLIDES.map((slide) => (
            <CarouselItem key={slide}>
              <Card style={styles.slide}>
                <span {...stylex.props(styles.number)}>{slide}</span>
              </Card>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
      <p {...stylex.props(styles.status)}>{`Slide ${current} of ${count}`}</p>
    </div>
  );
}

const styles = stylex.create({
  root: {
    display: 'flex',
    flexDirection: 'column',
    gap: space.s2,
    maxWidth: container.md,
    width: '100%',
  },
  slide: {
    alignItems: 'center',
    aspectRatio: '1',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
  status: {
    color: colors.mutedForeground,
    fontSize: fontSize.sm,
    margin: 0,
    textAlign: 'center',
  },
});
```

## Autoplay

Autoplay is an Embla plugin from a separate package, `embla-carousel-autoplay`, that you install yourself and pass through `plugins`.

```bash
npm install embla-carousel-autoplay
```

Autoplay attaches its mouse listeners to the scroll viewport, which does not contain the previous/next buttons. Pass `rootNode: (emblaRoot) => emblaRoot.parentElement` so hovering the buttons pauses it too. Its `stopOnFocusIn` only reacts to focus landing on a slide, so pause and resume from `onFocus`/`onBlur` on `Carousel` as well — without that a keyboard user has no way to stop the slides.

```tsx
'use client';

import * as React from 'react';

import * as stylex from '@stylexjs/stylex';
import Autoplay from 'embla-carousel-autoplay';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight } from '@/lib/constants.stylex';

export default function CarouselAutoplay() {
  // One plugin instance for the component's lifetime; it pauses while the
  // pointer is over the carousel and keeps going after button clicks.
  // `rootNode` moves Autoplay's mouse listeners from the scroll viewport up
  // to the carousel root, so hovering the previous/next buttons — which sit
  // outside the viewport — pauses it too.
  const [autoplay] = React.useState(() =>
    Autoplay({
      delay: 2000,
      rootNode: (emblaRoot) => emblaRoot.parentElement,
      stopOnFocusIn: true,
      stopOnInteraction: false,
      stopOnMouseEnter: true,
    })
  );

  return (
    <Carousel
      opts={{ loop: true }}
      plugins={[autoplay]}
      style={styles.carousel}
      // Autoplay's own `stopOnFocusIn` reacts to focus landing on a slide, so
      // the previous/next buttons would never pause it and a keyboard user
      // could not stop the slides. Pause on any focus inside the carousel and
      // resume when focus leaves, unless the pointer is still over it —
      // Autoplay restarts that case itself on mouse leave.
      onFocus={() => autoplay.stop()}
      onBlur={(event) => {
        if (event.currentTarget.contains(event.relatedTarget)) return;
        if (!event.currentTarget.matches(':hover')) autoplay.play();
      }}
    >
      <CarouselContent>
        {Array.from({ length: 5 }, (_, index) => (
          <CarouselItem key={index}>
            <Card style={styles.slide}>
              <span {...stylex.props(styles.number)}>{index + 1}</span>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  );
}

const styles = stylex.create({
  carousel: {
    maxWidth: container.md,
    width: '100%',
  },
  slide: {
    alignItems: 'center',
    aspectRatio: '1',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
});
```

## Loop

```tsx
import * as stylex from '@stylexjs/stylex';

import { Card } from '@/components/ui/card';
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel';
import { container, fontSize, fontWeight } from '@/lib/constants.stylex';

export default function CarouselLoop() {
  return (
    <Carousel opts={{ loop: true }} style={styles.carousel}>
      <CarouselContent>
        {Array.from({ length: 5 }, (_, index) => (
          <CarouselItem key={index}>
            <Card style={styles.slide}>
              <span {...stylex.props(styles.number)}>{index + 1}</span>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  );
}

const styles = stylex.create({
  carousel: {
    maxWidth: container.md,
    width: '100%',
  },
  slide: {
    alignItems: 'center',
    aspectRatio: '1',
    justifyContent: 'center',
  },
  number: {
    fontSize: fontSize.xl,
    fontWeight: fontWeight.semibold,
  },
});
```

## API reference

Built on [Embla Carousel](https://www.embla-carousel.com/). `opts` and `plugins` are passed straight to Embla — see the [Embla options](https://www.embla-carousel.com/api/options/) and [plugins](https://www.embla-carousel.com/plugins/) references. The carousel has no `variant` or `size` of its own; the navigation buttons take `Button`'s.

The root is a `region` with `aria-roledescription="carousel"` and a default `aria-label` of "Carousel" — override it with your own `aria-label` when a page holds more than one. Each item is a `group` with `aria-roledescription="slide"`; give slides an `aria-label` such as `"1 of 5"` when their content does not name them.

Arrow keys scroll to the previous/next slide while focus is inside the carousel. They are ignored when the focused element is an input, textarea, select, or `contenteditable`, so form controls on a slide keep their own arrow-key behaviour.

### Button placement

`CarouselPrevious` and `CarouselNext` sit in the gutter outside the viewport from 640px up, and overlay the leading/trailing edge of the track below that, where there is no gutter to hang in. Leave `space.s12` of room on the sides (horizontal) or above and below (vertical) at the wider sizes — the vertical example does this with `marginBlock`. Override `left`/`right`/`top`/`bottom` through `style` to place them somewhere else.

### Carousel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `opts` | `CarouselOptions` | — | Embla options (`loop`, `align`, `dragFree`, …). `axis` is derived from `orientation`. |
| `plugins` | `CarouselPlugins` | — | Embla plugins, e.g. `Autoplay()`. |
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` |  |
| `setApi` | `(api: CarouselApi) => void` | — | Receives the Embla API once the carousel has initialised. |
| `style` | `StyleXStyles` | — | StyleX styles merged last — always win over the component's own styles. |

`CarouselApi` is Embla's carousel instance type, exported for `setApi` callbacks and `useState<CarouselApi>()`. `useCarousel()` exposes the same context the parts use: `api`, `orientation`, `scrollPrev`, `scrollNext`, `scrollTo`, `canScrollPrev`, `canScrollNext`, `selectedIndex`, `snapCount`.

### CarouselPrevious, CarouselNext

Render `Button` and accept all of its props. They are disabled when there is nothing to scroll to.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `ButtonVariant` | `'outline'` |  |
| `size` | `ButtonSize` | `'iconSm'` |  |
| `style` | `StyleXStyles` | — | StyleX styles merged last — always win over the component's own styles. |

### CarouselDots

One button per scroll snap, labelled "Go to slide N"; the active dot has `aria-current="true"`. Each dot is an 8px mark inside a 24px pointer target. The row reserves its height so it does not shift the page when Embla reports the snap count on mount. Accepts all native `div` props.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `style` | `StyleXStyles` | — | StyleX styles merged last — always win over the component's own styles. |

### Slide size and spacing

`CarouselItem` defaults to `flexBasis: 100%` (one slide per view); pass a smaller `flexBasis` through `style` for more. The gap comes from a negative `marginLeft` (horizontal) / `marginTop` (vertical) on `CarouselContent` matched by `paddingLeft` / `paddingTop` on each `CarouselItem` — override both with the same value to change it. `CarouselContent`'s `style` and other props land on the inner slide track, which is also where a vertical carousel gets its fixed `height`.

### Styling

`Carousel`, `CarouselContent`, `CarouselItem`, `CarouselPrevious`, `CarouselNext`, `CarouselDots` accept `style` (`StyleXStyles`, merged last so caller overrides always win) plus all native props of the element they render.
