Carousel

A scroll-snap carousel with a dot stepper: one dot per slide, the active one stretched into a pill.

Installation

bash
npx @smicolon/fasla-ui@latest add carousel

Run 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.

The old town at dawn
The harbour market
The corniche at sunset

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.

2
Slide 1
Slide 2
3
Slide 1
Slide 2
Slide 3
4
Slide 1
Slide 2
Slide 3
Slide 4

States

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.

The old town at dawn
The harbour market
The corniche at sunset

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.

The old town at dawn

Accessibility

  • The root is a region with aria-roledescription set to carousel. Always pass an aria-label to name it.
  • Each slide is a group named with digits, “2 / 3”, so the name needs no locale; pass aria-label on a slide to name it by its content instead.
  • The dots are real buttons: Tab reaches them, the active one carries aria-current, and label renames 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 ring colour. 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-motion makes 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

PropTypeDefaultDescription
activeIndexnumber—Active slide, when controlled.
defaultActiveIndexnumber0Slide shown at first.
onActiveIndexChange(index) => void—Called when the active slide changes, dots and manual scrolling alike.
aria-labelstring—Names the carousel for assistive technology. Always pass it, or aria-labelledby.
childrenReactNode—The track, CarouselContent, and the dots, CarouselDots.
classNamestring—Extra classes for the part.

CarouselContent

PropTypeDefaultDescription
childrenReactNode—The slides, one CarouselItem each.
classNamestring—Extra classes for the part.

CarouselItem

PropTypeDefaultDescription
aria-labelstring—Names the slide by its content. The default is its position, “2 / 3”.
childrenReactNode—The slide's content.
classNamestring—Extra classes. Override basis-full to show more than one slide per view.

CarouselDots

PropTypeDefaultDescription
countnumber—How many dots, for dots on their own. Leave it out inside a Carousel, which counts its own slides.
activeIndexnumber0The 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”.
classNamestring—Extra classes for the part.

Usage

Slides with a dot stepper

tsx
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

tsx
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

tsx
"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

tsx
"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>
  )
}