Carousel
A slide carousel with previous/next buttons and dots, horizontal or vertical.
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
npx @madeui/cli add carousel
Installs embla-carousel-react alongside the component.
Usage
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
<Carousel>
<CarouselContent>
<CarouselItem>…</CarouselItem>
<CarouselItem>…</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
Composition
<Carousel>
<CarouselContent>
<CarouselItem />
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
Sizes
Set flexBasis on CarouselItem through style to show several slides per view.
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.
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.
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
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.
Slide 1 of 5
'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.
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.
'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
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. opts and plugins are passed straight to Embla — see the Embla options and 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.