Carousel
A row of slides on the browser's own scroll snapping: swipe it, scroll it, drag it, or use the arrows and dots.
- Drag the slides with the mouse, or scroll sideways: they settle on a slide. A drag that ends on a link does not open it.
- Tab to the slides, then use the arrow keys, Home and End. The dots are one tab stop, moved with the arrow keys.
- On the last slide Next is dimmed but keeps focus. Each move is announced: "3 of 5".
Field marks
- Import
import { Carousel, CarouselContent, CarouselItem, CarouselControls } from "cennge-ui";- Size
- 6.6 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA carousel: a named region of slides, each named "N of M"
- Keyboard
- Arrow left Arrow right HomeEnd
- State
- Controlled with
indexandonIndexChange, or uncontrolled fromdefaultIndex - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Scrolling
- Native CSS scroll snapping, no carousel library; the mouse drags it
- Autoplay
- Pauses under the mouse, stops when focus comes in, always has a pause button
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: carousel
- Source
- src/components/carousel
When to use#
A Carousel fits a set of similar items that would not all fit side by side and that people look at one or a few at a time: releases, featured repositories, screenshots. Fingers, trackpads and the keyboard scroll it the way they scroll anything else, because underneath it is a scrolling row with CSS scroll snapping.
Do not hide something everyone must read in slide four. If the items matter equally, a list or a grid shows them all.
Similar#
- Tabs switch between panels by name; a Carousel moves along a row in order.
- ScrollArea scrolls any content with themed scrollbars, without slides, stops or controls.
Several slides at a time#
slidesPerView sets the CSS variable --carousel-per-view inline. To change it at a breakpoint, leave the prop out and set the variable with classes, as here. A fraction shows the edge of the next slide. The dots then count pages, the positions where the row can stop, while Previous and Next move one slide.
import { Carousel, CarouselContent, CarouselControls, CarouselItem } from "cennge-ui";
const repositories = [
{ name: "cennge-ui", description: "React UI library" },
{ name: "ai-niche-scout", description: "Finds niches worth building for" },
{ name: "payload-cache", description: "FastAPI cache service" },
{ name: "spredo-crypto-screener", description: "Crypto screener" },
{ name: "playwright-msi-scraper", description: "Scraper on Playwright" },
];
export default function RepositoryCarousel() {
return (
<Carousel
aria-label="Repositories"
gap="0.75rem"
// 1.2 on phones shows the edge of the next card; two from 640px, three from 1024px.
className="w-full [--carousel-per-view:1.2] sm:[--carousel-per-view:2] lg:[--carousel-per-view:3]"
>
<CarouselContent>
{repositories.map((repository) => (
<CarouselItem key={repository.name}>
<div className="flex h-28 flex-col justify-between rounded-large bg-surface-3 p-4">
<span className="truncate font-semibold">{repository.name}</span>
<span className="text-sm text-muted">{repository.description}</span>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselControls />
</Carousel>
);
}Autoplay#
autoplay moves on every five seconds, or every given number of milliseconds, and goes back to the first slide after the last; loop lets Next and Previous wrap around too. It pauses while the mouse is over the carousel or the tab is hidden, and stops when focus comes into it or the mouse drags it, until the play button starts it again. With reduced motion it never starts on its own. While it plays, moves are not announced.
import { Carousel, CarouselContent, CarouselControls, CarouselItem } from "cennge-ui";
const tips = [
{ title: "One attribute for the accent", text: 'Set data-accent="blue" on any element and everything inside takes the blue accent.' },
{ title: "Every colour is a variable", text: "Colours, radii and surfaces are --cennge-* CSS variables: recolour without a rebuild." },
{ title: "Search from anywhere", text: "Press Ctrl K, or Command K on a Mac, to search these docs." },
];
export default function Tips() {
return (
<Carousel aria-label="Tips" autoplay loop className="w-full max-w-md">
<CarouselContent>
{tips.map((tip) => (
<CarouselItem key={tip.title}>
<div className="flex h-32 flex-col gap-2 rounded-large bg-surface-3 p-5">
<p className="font-semibold">{tip.title}</p>
<p className="text-sm text-muted">{tip.text}</p>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselControls />
</Carousel>
);
}Your own controls#
useCarousel() inside the carousel gives the position, the count and the moves; CarouselPrevious and CarouselNext can go anywhere inside it. Here they sit in a header, and the carousel takes its name from the visible title through aria-labelledby. For a gallery of pictures, <CarouselContent arrows> puts Previous and Next over the slides instead.
New in cennge-ui 0.3.0
import { useId } from "react";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, useCarousel } from "cennge-ui";
const charts = [
{ name: "BarChart", text: "Columns or horizontal bars, grouped or stacked." },
{ name: "LineChart", text: "Change over time, straight or smooth; a missing value breaks the line." },
{ name: "AreaChart", text: "Lines with a wash below, stacked or not." },
{ name: "DonutChart", text: "A whole and its parts, as a ring with the total in the middle or as a pie." },
{ name: "Sparkline", text: "A trend at a glance, beside the number it belongs to." },
];
/** Inside <Carousel>, useCarousel gives the position and the moves. */
function Header({ titleId }: { titleId: string }) {
const { index, count } = useCarousel();
return (
<div className="flex items-center gap-2">
<p id={titleId} className="font-semibold">
New in cennge-ui 0.3.0
</p>
<span className="ml-auto text-sm text-muted tabular-nums" aria-hidden="true">
{index + 1} / {count}
</span>
<CarouselPrevious />
<CarouselNext />
</div>
);
}
export default function ChartsCarousel() {
const titleId = useId();
return (
<Carousel aria-labelledby={titleId} className="w-full max-w-md">
<Header titleId={titleId} />
<CarouselContent>
{charts.map((chart) => (
<CarouselItem key={chart.name}>
<div className="flex h-28 flex-col gap-1.5 rounded-large bg-surface-3 p-5">
<code className="font-mono font-semibold text-primary-text">{chart.name}</code>
<p className="text-sm text-muted">{chart.text}</p>
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
);
}Accessibility#
The carousel is a <section> with the role description "carousel" once it has a name, from aria-label or aria-labelledby; without one it stays a plain section. Each slide is a group named "3 of 5" unless you give it a name. The row of slides is one tab stop; links and buttons inside the slides keep their own. Previous and Next use aria-disabled rather than disabled at either end, so focus stays on them. A polite live region announces each move, and is silent while autoplay runs. Every name, from "carousel" to "Pause slide show", can be translated through labels.
Server rendering#
Slide names and the controls are in the server's HTML. Before the browser has measured anything, the carousel counts the slides inside a CarouselContent placed directly in it: every slide is already named "N of M", and the controls show a dot per slide. With several slides in view, the dots change to pages after the first measurement.
Props#
Carousel#
CarouselContent#
Also takes every attribute of <div>.
CarouselControls#
Also takes every attribute of <div>.