Carousel
- Stable
- WCAG 2.2 evidence
- RTL
A carousel: slides in a scroll-snap strip with Previous / Next, indicators and, when you opt in, automatic rotation
with a visible pause button. It composes CarouselControls and CarouselIndicator; the container has no Figma node (decision D8: no React Aria primitive, the WAI-ARIA carousel
pattern). Structure and names (WCAG 1.3.1, 4.1.2): a region (or group) named by you with aria-roledescription="carousel";
the controls first, then the slides, then the indicators; each CarouselSlide is a group with
aria-roledescription="slide" and a name like "3 of 8". The controls and indicators point at the slide strip with
aria-controls. Slides out of view are inert and aria-hidden, so focus and screen readers only meet what is on
screen (2.4.3). Keyboard (2.1.1): Previous, Next, the rotation button and the indicators are buttons (Tab, Enter, Space). The slide
strip is a scrollable region, and a scrollable region with nothing focusable in it cannot be reached or scrolled by
keyboard on its own (automated checks such as axe's scrollable-region-focusable flag it). So the strip is a tab stop
only while the slides in view hold nothing focusable: Arrow keys (mirrored in right-to-left layouts), Home and End
then move between slides. Where the visible slide has a link or a button, that is the focusable content, the strip
stays out of the tab order and no extra Tab stop is added. If focus is inside a slide that is about to go out of view,
focus moves to the strip instead of being lost. Motion (2.2.2, 2.3.3): rotation is off unless autoPlay, always comes with a visible Stop / Start button, pauses while
the pointer is over the carousel or focus is inside it, never starts by itself with prefers-reduced-motion (and
stops if the preference appears), and scrolls without animation then. While rotating the strip is aria-live="off",
otherwise polite, so a change of slide is announced only when the user caused it. Direction (1.3.2): the strip, Previous / Next and the keys follow the reading direction. Layout: the carousel is a
size container; slidesPerView can change with its width. Controls and indicators are hidden when there is only one
position. Server rendering outputs every slide, the first one in view, without any measurement. Consumer duties: a specific name, text alternatives of the pictures (Image's alt), enough time per slide, and a
link to every slide's content that does not depend on the carousel when the content matters (carousels hide most of
what they hold). Focusable slide content should use an inset focus ring where it touches the slide edge: the strip
clips overflow.
import { Carousel } from "@nexera-ui/react";- 8
- examples
- 22
- props
- 8
- live controls
- 1
- platform
- 9
- WCAG criteria
- 3
- blocks use it
Try every prop. Copy the code.
Change the props and the code updates. Check light and dark, LTR and RTL, and phone width.
import { Carousel } from "@nexera-ui/react";
<Carousel />Examples 7
The same examples as Storybook, rendered live. Open Code to copy one.
With images
Pictures with the controls and the indicator laid over them (surface="on-media"), looping. The slides are Images: each has its own alternative text, so a screen reader hears the picture and "3 of 6".
import { Carousel, CarouselSlide, Image } from "@nexera-ui/react";
const scene = (sky: string, ground: string) =>
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="${sky}"/>` +
`<circle cx="300" cy="80" r="36" fill="white" fill-opacity="0.85"/>` +
`<path d="M0 230 L120 140 L210 210 L290 160 L400 230 L400 300 L0 300 Z" fill="${ground}"/></svg>`,
)}`;
const PHOTOS = [
{ src: scene("navajowhite", "peru"), alt: "Team photo at the March offsite" },
{ src: scene("lightsteelblue", "seagreen"), alt: "Open-plan office, second floor" },
{ src: scene("lightblue", "darkolivegreen"), alt: "Hills behind the retreat venue" },
{ src: scene("thistle", "slateblue"), alt: "Welcome desk at reception" },
{ src: scene("khaki", "sienna"), alt: "Workshop room set up for twelve" },
{ src: scene("lightcyan", "teal"), alt: "Rooftop terrace at sunset" },
] as const;
const pictureSlides = PHOTOS.map((photo) => (
<CarouselSlide key={photo.alt}>
<Image src={photo.src} alt={photo.alt} ratio="16-9" />
</CarouselSlide>
));
<Carousel aria-label="Photos from the offsite" className="max-w-xl" surface="on-media" isLooping>
{pictureSlides}
</Carousel>;
Auto play
Opt-in automatic rotation (autoPlay, interval). The Stop / Start button is always there (WCAG 2.2.2); rotation also pauses while the pointer is over the carousel or focus is inside it, and never starts by itself when the operating system asks for reduced motion. While it rotates the slide strip is aria-live="off", otherwise polite.
import { Carousel, CarouselSlide } from "@nexera-ui/react";
const HIGHLIGHTS = [
{ title: "Welcome week", text: "Meet your buddy on day two.", tone: "bg-status-info-bg" },
{ title: "Benefits", text: "Enrol before the end of the month.", tone: "bg-status-success-bg" },
{ title: "Leave", text: "Request time off from the Leave page.", tone: "bg-status-warning-bg" },
{ title: "Payroll", text: "Payslips arrive on the 25th.", tone: "bg-status-leave-bg" },
{ title: "Learning", text: "Pick two courses for the quarter.", tone: "bg-brand-subtle" },
] as const;
const textSlides = HIGHLIGHTS.map((slide) => (
<CarouselSlide key={slide.title}>
<div className={`box-border h-full rounded-lg p-6 ${slide.tone}`}>
<h3 className="m-0 text-heading-h4 text-primary">{slide.title}</h3>
<p className="m-0 mt-1 text-body-default text-primary">{slide.text}</p>
</div>
</CarouselSlide>
));
<Carousel
aria-label="Rotating highlights"
className="max-w-xl"
autoPlay
interval={4000}
isLooping
indicator="lines"
>
{textSlides}
</Carousel>;
Multiple slides per view
Several slides in view: slidesPerView takes a number or one number per width of the carousel's own container (here 1, then 2 from 480 px, then 3 from 1024 px). The slides are ImageTile buttons, so they are the focusable content and the strip itself is not a tab stop. One position per slide that can start the view, so there are fewer dots than tiles.
import { Carousel, CarouselSlide, ImageTile } from "@nexera-ui/react";
const scene = (sky: string, ground: string) =>
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="${sky}"/>` +
`<circle cx="300" cy="80" r="36" fill="white" fill-opacity="0.85"/>` +
`<path d="M0 230 L120 140 L210 210 L290 160 L400 230 L400 300 L0 300 Z" fill="${ground}"/></svg>`,
)}`;
const PHOTOS = [
{ src: scene("navajowhite", "peru"), alt: "Team photo at the March offsite" },
{ src: scene("lightsteelblue", "seagreen"), alt: "Open-plan office, second floor" },
{ src: scene("lightblue", "darkolivegreen"), alt: "Hills behind the retreat venue" },
{ src: scene("thistle", "slateblue"), alt: "Welcome desk at reception" },
{ src: scene("khaki", "sienna"), alt: "Workshop room set up for twelve" },
{ src: scene("lightcyan", "teal"), alt: "Rooftop terrace at sunset" },
] as const;
const tileSlides = PHOTOS.map((photo) => (
<CarouselSlide key={photo.alt}>
<ImageTile src={photo.src} alt={photo.alt} onPress={() => undefined} />
</CarouselSlide>
));
<Carousel aria-label="Gallery" className="max-w-3xl" slidesPerView={{ base: 1, sm: 2, lg: 3 }}>
{tileSlides}
</Carousel>;
Counter
The counter indicator ("2 / 6", read as "Slide 2 of 6"): one Tab stop fewer per slide in a long carousel.
import { Carousel, CarouselSlide, Image } from "@nexera-ui/react";
const scene = (sky: string, ground: string) =>
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="${sky}"/>` +
`<circle cx="300" cy="80" r="36" fill="white" fill-opacity="0.85"/>` +
`<path d="M0 230 L120 140 L210 210 L290 160 L400 230 L400 300 L0 300 Z" fill="${ground}"/></svg>`,
)}`;
const PHOTOS = [
{ src: scene("navajowhite", "peru"), alt: "Team photo at the March offsite" },
{ src: scene("lightsteelblue", "seagreen"), alt: "Open-plan office, second floor" },
{ src: scene("lightblue", "darkolivegreen"), alt: "Hills behind the retreat venue" },
{ src: scene("thistle", "slateblue"), alt: "Welcome desk at reception" },
{ src: scene("khaki", "sienna"), alt: "Workshop room set up for twelve" },
{ src: scene("lightcyan", "teal"), alt: "Rooftop terrace at sunset" },
] as const;
const pictureSlides = PHOTOS.map((photo) => (
<CarouselSlide key={photo.alt}>
<Image src={photo.src} alt={photo.alt} ratio="16-9" />
</CarouselSlide>
));
<Carousel aria-label="Photo gallery" className="max-w-xl" indicator="counter">
{pictureSlides}
</Carousel>;
Controlled
Controlled index and onIndexChange: the position lives in the parent, which can move it too.
import { useState } from "react";
import { Carousel } from "@nexera-ui/react";
const HIGHLIGHTS = [
{ title: "Welcome week", text: "Meet your buddy on day two.", tone: "bg-status-info-bg" },
{ title: "Benefits", text: "Enrol before the end of the month.", tone: "bg-status-success-bg" },
{ title: "Leave", text: "Request time off from the Leave page.", tone: "bg-status-warning-bg" },
{ title: "Payroll", text: "Payslips arrive on the 25th.", tone: "bg-status-leave-bg" },
{ title: "Learning", text: "Pick two courses for the quarter.", tone: "bg-brand-subtle" },
] as const;
export function Controlled() {
const [index, setIndex] = useState(2);
return (
<div className="flex max-w-xl flex-col gap-3">
<Carousel
aria-label="Onboarding highlights"
className="max-w-xl"
index={index}
onIndexChange={setIndex}
/>
<p className="m-0 text-body-default text-primary" role="status">
{`Showing highlight ${String(index + 1)} of ${String(HIGHLIGHTS.length)}`}
</p>
<button
type="button"
className="w-fit rounded-md border border-solid border-input-border bg-surface px-3 py-2 text-body-default text-primary"
onClick={() => {
setIndex(0);
}}
>
Back to the first highlight
</button>
</div>
);
}
RTL
Right to left: the strip scrolls from the right, Previous points right, the indicators and the arrow keys follow the reading direction, and the names are translated (labels, getSlideLabel, getIndicatorLabel).
import { Carousel, CarouselSlide } from "@nexera-ui/react";
export function RTL() {
return (
<div dir="rtl" lang="ar" className="max-w-xl">
<Carousel
className="max-w-xl"
aria-label="أبرز المستجدات"
labels={{
previous: "الشريحة السابقة",
next: "الشريحة التالية",
play: "بدء العرض التلقائي",
pause: "إيقاف العرض التلقائي",
controls: "عناصر التحكم في الشرائح",
slides: "الشرائح",
indicators: "اختيار شريحة",
}}
getSlideLabel={(index, count) => `${String(index + 1)} من ${String(count)}`}
getIndicatorLabel={(index, count) =>
`الانتقال إلى الشريحة ${String(index + 1)} من ${String(count)}`
}
autoPlay
defaultPlaying={false}
>
{[
["أسبوع الترحيب", "تعرّف على زميلك المرشد في اليوم الثاني."],
["المزايا", "سجّل قبل نهاية الشهر."],
["الإجازات", "اطلب إجازتك من صفحة الإجازات."],
].map(([title, text]) => (
<CarouselSlide key={title}>
<div className="box-border h-full rounded-lg bg-status-info-bg p-6">
<h3 className="m-0 text-heading-h4 text-primary">{title}</h3>
<p className="m-0 mt-1 text-body-default text-primary">{text}</p>
</div>
</CarouselSlide>
))}
</Carousel>
</div>
);
}
Narrow container
Container-based, not viewport-based: the same slidesPerView setting shows one slide in a 240 px column and two in a wide one. Many slides make the indicators wrap instead of overflowing (WCAG 1.4.10).
import { Carousel, CarouselSlide } from "@nexera-ui/react";
const manySlides = Array.from({ length: 12 }, (_, index) => (
<CarouselSlide key={index}>
<div className="box-border h-full rounded-lg bg-brand-subtle p-4">
<h3 className="m-0 text-heading-h4 text-primary">{`Tip ${String(index + 1)}`}</h3>
<p className="m-0 mt-1 text-body-default text-primary">Keep slides short.</p>
</div>
</CarouselSlide>
));
export function NarrowContainer() {
return (
<div className="flex flex-wrap items-start gap-8">
<div className="w-60 max-w-full">
<Carousel
className="max-w-xl"
aria-label="Narrow column"
slidesPerView={{ base: 1, sm: 2 }}
>
{manySlides}
</Carousel>
</div>
<div className="w-full max-w-2xl">
<Carousel className="max-w-xl" aria-label="Wide column" slidesPerView={{ base: 1, sm: 2 }}>
{manySlides}
</Carousel>
</div>
</div>
);
}
Props 22
Press "Try it" on a card to load that prop into the playground.
22 props shown
children*NexeraReactNodeThe slides: CarouselSlide elements, each one a direct child (fragments are not unpacked). The number of children is
the number of slides; null, false and undefined do not count.
roleNexera"region" | "group"Role of the root: region (a landmark that appears in landmark lists, for a carousel that is a main part of the page)
or group (for a carousel inside other content). Either way it is announced as a "carousel".
slidesPerViewNexeraCarouselSlidesPerViewSlides in view at once: a whole number of at least 1 (smaller or fractional values are corrected), or an object with
one number per container width ({ base: 1, sm: 2, lg: 3 }, see CarouselResponsiveSlides). The widths are those of
the carousel's own container (@nx-sm and up), not the viewport. Previous and Next move one slide at a time and
the last position is the one that shows the last slide in the last place. The CSS follows the container without JavaScript. The server renders (and the first client render uses) the base
number for which slides are inert; after hydration the container is measured and the other numbers apply.
indexNexeranumberPosition of the first slide in view, from 0 (controlled). Pair it with onIndexChange. With several slides per view
the highest position is slides - slidesPerView.
defaultIndexNexeranumberPosition at first render (uncontrolled). The server output always starts at the first slide; a higher value scrolls into place when the page hydrates.
onIndexChangeNexera(index: number) => voidCalled with the new position when it changes: Previous, Next, an indicator, the arrow keys on the focused slide strip, a swipe or scroll that settles on another slide, or the automatic rotation.
isLoopingNexerabooleanWrap around: Next on the last position goes back to the first, Previous on the first goes to the last, and the
automatic rotation starts over. The strip scrolls back; slides are not cloned, so assistive technology never meets a
duplicate. Without it the ends are aria-disabled and the automatic rotation stops at the last slide.
autoPlayNexerabooleanRotate slides automatically (opt in). Adds the rotation button (Stop / Start automatic slide show, WCAG 2.2.2) next to
Previous and Next. Rotation pauses while the pointer is over the carousel or keyboard focus is inside it (the rotation
button itself excepted, so pressing Start works), and does not start by itself when the user prefers reduced motion; the
button is still there to start it deliberately. The slide strip is aria-live="off" while rotating and polite when
not, as the WAI-ARIA carousel pattern asks.
intervalNexeranumberTime each slide stays in view while rotating, in milliseconds. Pressing Previous, Next or an indicator restarts the count. Give people time to read: allow for the slide's text.
isPlayingNexerabooleanWhether the slides rotate right now (controlled), shown by the rotation button. Only with autoPlay. Pair it with
onPlayingChange.
defaultPlayingNexerabooleanRotation state at first render (uncontrolled), only with autoPlay. Pass false to wait for the user to press Start.
Reduced motion starts paused whatever this says.
onPlayingChangeNexera(isPlaying: boolean) => voidCalled with the new rotation state when the user presses the rotation button, when the preference for reduced motion stops rotation, and when rotation stops at the last slide of a carousel that is not looping.
indicatorNexera"dots" | "lines" | "counter" | "none"Which indicator shows the position: dots, lines or counter ("2 / 5", text, not
interactive), or none. One indicator per position, so with several slides per view there are fewer than slides. All
but counter are buttons and one Tab stop each; counter or none keep the tab order short in long carousels.
surfaceNexera"on-light" | "on-media"Background the controls and the indicator sit on: on-light puts them above and below the slides;
on-media lays them over the bottom of the slides (white marks and buttons, for pictures that stay dark enough
behind them: check the contrast, WCAG 1.4.11).
labelsNexeraPartial<CarouselLabels>Accessible names of the controls, the slide strip and the indicators; English defaults. Translate them.
getSlideLabelNexera(index: number, count: number) => stringName of each slide (WCAG 4.1.2), "3 of 8" in English. A slide's own aria-label wins. Translate it.
getIndicatorLabelNexera(index: number, count: number) => stringName of each indicator button; index is the position it goes to and count the number of slides. Translate it.
getCounterLabelNexera(index: number, count: number) => stringText read by assistive technology for indicator="counter". Translate it.
classNameNexerastringExtra classes for the root, merged last so they win over the defaults.
styleNexeraCSSPropertiesInline style for the root.
aria-labelNexerastringAccessible name of the carousel ("Team photos"). Required unless aria-labelledby is set. Translate it.
Accessible name of the carousel. Optional when aria-labelledby is set.
aria-labelledbyNexerastringId(s) of visible element(s) that name the carousel; wins over aria-label when both are set.
Id(s) of visible element(s) that name the carousel (for example its heading). Required unless aria-label is set.
* Required. React Aria props shown are the ones most apps use; the component accepts the rest of its React Aria props too.
Accessible by default.
Built on React Aria, and covered by the WCAG 2.2 evidence generated on every build.
WCAG 2.2 evidence
7 direct · 2 supporting- 1.3.1Info and RelationshipsLevel A · tested directly
- 1.3.2Meaningful SequenceLevel A · tested directly
- 1.4.10ReflowLevel AA · supporting test
- 2.1.1KeyboardLevel A · tested directly
- 2.2.2Pause, Stop, HideLevel A · tested directly
- 2.3.3Animation from InteractionsLevel AAA · tested directly
- 2.4.3Focus OrderLevel A · tested directly
- 2.4.7Focus VisibleLevel AA · supporting test
- 4.1.2Name, Role, ValueLevel A · tested directly
Fits any width.
Nexera components respond to the space they are given. Drag the corner of the frame, or pick a width.
Styling hooks
Pass className to add Tailwind classes (merged last). State is exposed as data attributes, so you can style it with variants like data-pressed:.
<Carousel className="shadow-sm" />Used in blocks
Related components
- CarouselControlsPrevious / Next buttons for a carousel, and optionally a button that stops and starts automatic rotation.
- CarouselIndicatorShows which slide of a carousel is current, and lets people jump to a slide.
- ImageA picture in a rounded frame with a fixed aspect ratio: Figma `State=Loading` (a `bg/sunken` frame with a shimmer), `Loaded` and `Error` ("Image unavailable") follow the load lifecycle of the `<img>`; they are not props.
- ImageTileA square picture in a gallery or picker, with overlay actions on hover and a selected state.
- VideoControlsThe control bar of a video: scrubber, play / pause, back, mute and volume, time, captions, settings, picture in picture and full screen, over a dark gradient.
- VideoPlayerA video with a poster, Nexera controls and captions.