Carousel
A scroll-snap carousel with a dot stepper: one dot per slide, the active one stretched into a pill.
Installation
npx @smicolon/fasla-ui@latest add carouselRun init once per project before adding components. See Installation.
Preview
The track is a native scroller with CSS scroll snapping, so swipe, trackpad and the dots all move it. The active dot follows whichever way you scroll.
Variants
Figma's No. of items axis is simply how many slides you render: the dot row follows the slide count, and the pill is its Active Item.
234States
The active dot is a 24px primary pill, the rest are 8px muted-foreground circles, and the stretch between them animates unless motion is reduced. defaultActiveIndex starts on a later slide, jumping there instantly rather than scrolling.
Dots on their own
Give CarouselDots a count and it needs no Carousel around it: it shows activeIndex and reports each press through onActiveIndexChange, so a gallery or a stepper can drive it with state of its own.
Accessibility
- The root is a
regionwitharia-roledescriptionset tocarousel. Always pass anaria-labelto name it. - Each slide is a
groupnamed with digits, “2 / 3”, so the name needs no locale; passaria-labelon a slide to name it by its content instead. - The dots are real buttons: Tab reaches them, the active one carries
aria-current, andlabelrenames them for your locale. - The track is a tab stop, and the arrow keys move one slide from anywhere inside — following the text direction, so the key that points forward on screen always goes forward. Home jumps to the first slide, End to the last.
- Keyboard focus draws a 2px ring, 2px outside the dot or the track, in the
ringcolour. On a dot it follows the circle or the pill, not the larger area you can press. - Smooth scrolling comes from CSS alone, so
prefers-reduced-motionmakes every move instant without any JavaScript. - Direction is inherited from
dir, never a prop. The track, the snapping and the dots all follow the line's direction.
Props
Carousel
| Prop | Type | Default | Description |
|---|---|---|---|
activeIndex | number | — | Active slide, when controlled. |
defaultActiveIndex | number | 0 | Slide shown at first. |
onActiveIndexChange | (index) => void | — | Called when the active slide changes, dots and manual scrolling alike. |
aria-label | string | — | Names the carousel for assistive technology. Always pass it, or aria-labelledby. |
children | ReactNode | — | The track, CarouselContent, and the dots, CarouselDots. |
className | string | — | Extra classes for the part. |
CarouselContent
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The slides, one CarouselItem each. |
className | string | — | Extra classes for the part. |
CarouselItem
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Names the slide by its content. The default is its position, “2 / 3”. |
children | ReactNode | — | The slide's content. |
className | string | — | Extra classes. Override basis-full to show more than one slide per view. |
CarouselDots
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | — | How many dots, for dots on their own. Leave it out inside a Carousel, which counts its own slides. |
activeIndex | number | 0 | The active dot, for dots on their own. |
onActiveIndexChange | (index) => void | — | Called with a dot's index when it is pressed, for dots on their own. |
label | (index, count) => string | — | Names each dot for your locale. The default is its position, “2 / 3”. |
className | string | — | Extra classes for the part. |
Usage
Slides with a dot stepper
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
export function CityGuide() {
return (
<Carousel aria-label="City guide" className="space-y-4">
<CarouselContent>
<CarouselItem>The old town at dawn</CarouselItem>
<CarouselItem>The harbour market</CarouselItem>
<CarouselItem>The corniche at sunset</CarouselItem>
</CarouselContent>
<CarouselDots />
</Carousel>
)
}Start on a later slide
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
export function CityGuide() {
return (
<Carousel aria-label="City guide" defaultActiveIndex={2} className="space-y-4">
<CarouselContent>
<CarouselItem>The old town at dawn</CarouselItem>
<CarouselItem>The harbour market</CarouselItem>
<CarouselItem>The corniche at sunset</CarouselItem>
</CarouselContent>
<CarouselDots />
</Carousel>
)
}Dots on their own
"use client"
import * as React from "react"
import { CarouselDots } from "@/components/ui/carousel"
const PHOTOS = ["The old town at dawn", "The harbour market", "The corniche at sunset"]
export function PhotoStepper() {
const [index, setIndex] = React.useState(0)
return (
<div className="flex flex-col gap-4">
<p className="text-sm font-medium">{PHOTOS[index]}</p>
<CarouselDots count={PHOTOS.length} activeIndex={index} onActiveIndexChange={setIndex} />
</div>
)
}Controlled
"use client"
import * as React from "react"
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
export function CityGuide() {
const [index, setIndex] = React.useState(0)
return (
<Carousel
aria-label="City guide"
activeIndex={index}
onActiveIndexChange={setIndex}
className="space-y-4"
>
<CarouselContent>
<CarouselItem>The old town at dawn</CarouselItem>
<CarouselItem>The harbour market</CarouselItem>
<CarouselItem>The corniche at sunset</CarouselItem>
</CarouselContent>
<CarouselDots />
</Carousel>
)
}