HextaUI

Carousel

Des slides à scroll-snap natif avec inertie au toucher, glisser à la souris, flèches du clavier, points, miniatures et une lecture automatique qui se met en pause au bon moment.

import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const slides = [1, 2, 3, 4, 5]

export function CarouselDemo() {
  return (
    <div className="w-full max-w-xs px-12">
      <Carousel aria-label="Numbers">
        <CarouselContent>
          {slides.map((value) => (
            <CarouselItem key={value}>
              <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
                {value}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
        <div className="mt-3 flex justify-center">
          <CarouselDots />
        </div>
      </Carousel>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/carousel.json

Ajoute le composant, les tokens de thème HextaUI et les composants HextaUI dont il dépend.

import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"
<Carousel aria-label="Featured">
  <CarouselContent>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
  <CarouselDots />
</Carousel>

Les diapositives défilent nativement grâce au scroll snap CSS : l’inertie tactile et le défilement au pavé tactile se comportent comme sur la plateforme. La souris peut faire glisser, et les flèches avancent d’une diapositive à la fois.

Carousel
├── CarouselContent
│   └── CarouselItem
├── CarouselPrevious
├── CarouselNext
├── CarouselDots
├── CarouselCounter
├── CarouselAutoplayToggle
└── CarouselThumbnails
    └── CarouselThumbnail

API

Passez setApi pour obtenir l’API du carrousel, puis écoutez select pour afficher votre propre position. Next est désactivé sur la dernière diapositive mais garde le focus.

"use client"

import * as React from "react"

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  type CarouselApi,
} from "@/components/ui/carousel"

const slides = [1, 2, 3, 4, 5]

export function CarouselWithApi() {
  const [api, setApi] = React.useState<CarouselApi>()
  const [current, setCurrent] = React.useState(0)
  const [count, setCount] = React.useState(0)

  React.useEffect(() => {
    if (!api) {
      return
    }
    const sync = () => {
      setCount(api.scrollSnapList().length)
      setCurrent(api.selectedScrollSnap() + 1)
    }
    sync()
    api.on("select", sync).on("reInit", sync)
    return () => {
      api.off("select", sync).off("reInit", sync)
    }
  }, [api])

  return (
    <div className="flex w-full max-w-xs flex-col items-center gap-2 px-12">
      <Carousel setApi={setApi} aria-label="Numbers" className="w-full">
        <CarouselContent>
          {slides.map((value) => (
            <CarouselItem key={value}>
              <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
                {value}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
      <p className="text-sm text-muted-foreground">
        Slide {current} of {count}
      </p>
    </div>
  )
}

Plusieurs par vue

Les éléments définissent leur propre basis. Les gouttières viennent de la prop spacing, elles restent donc exactes quel que soit le basis.

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

export function CarouselSeveralPerView() {
  return (
    <div className="w-full max-w-md px-12">
      <Carousel spacing="sm" aria-label="Several per view">
        <CarouselContent>
          {Array.from({ length: 9 }, (_, index) => (
            <CarouselItem key={index} className="basis-1/2 sm:basis-1/3">
              <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-3xl font-semibold">
                {index + 1}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  )
}

Points et compteur

Le point actif s’étire pendant le défilement des diapositives et ses voisins lui font de la place. Le compteur ne fait tourner que le chiffre qui a changé.

import {
  Carousel,
  CarouselContent,
  CarouselCounter,
  CarouselDots,
  CarouselItem,
} from "@/components/ui/carousel"

export function CarouselDotsAndCounter() {
  return (
    <Carousel aria-label="Dots and counter" className="w-full max-w-md">
      <CarouselContent>
        {Array.from({ length: 12 }, (_, index) => (
          <CarouselItem key={index} className="basis-4/5">
            <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
              {index + 1}
            </div>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="mt-3 flex items-center justify-between gap-4">
        <CarouselCounter />
        <CarouselDots />
      </div>
    </Carousel>
  )
}

Lecture automatique

autoplay est désactivé par défaut et le reste avec la réduction des animations. Il se met en pause au survol, au focus clavier, au toucher, au glissement, lorsque l’onglet est masqué ou que le carrousel sort de la vue, et le point actif se remplit pendant que le minuteur tourne.

import {
  Carousel,
  CarouselAutoplayToggle,
  CarouselContent,
  CarouselDots,
  CarouselItem,
} from "@/components/ui/carousel"

const slides = [1, 2, 3, 4, 5]

export function CarouselAutoplay() {
  return (
    <Carousel
      autoplay={{ delay: 3000 }}
      aria-label="Autoplay"
      className="w-full max-w-xs"
    >
      <CarouselContent>
        {slides.map((value) => (
          <CarouselItem key={value}>
            <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
              {value}
            </div>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="mt-3 flex items-center justify-center gap-2">
        <CarouselAutoplayToggle />
        <CarouselDots />
      </div>
    </Carousel>
  )
}

Miniatures

<CarouselThumbnails /> suit le carrousel principal et défile pour garder la miniature active visible.

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselThumbnail,
  CarouselThumbnails,
} from "@/components/ui/carousel"

const photos = Array.from({ length: 10 }, (_, index) => index + 1)

export function CarouselWithThumbnails() {
  return (
    <Carousel aria-label="Gallery" className="w-full max-w-md">
      <CarouselContent>
        {photos.map((photo) => (
          <CarouselItem key={photo}>
            <div className="relative overflow-hidden rounded-xl border">
              <img
                src="/preview/landscape.svg"
                alt={`Landscape ${photo}`}
                draggable={false}
                className="aspect-video w-full object-cover"
              />
              <span className="absolute start-3 top-3 rounded-md bg-background px-2 py-1 text-xs font-medium">
                {photo}
              </span>
            </div>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="mt-3">
        <CarouselThumbnails>
          {photos.map((photo) => (
            <CarouselThumbnail key={photo} className="w-20">
              <img
                src="/preview/landscape.svg"
                alt=""
                draggable={false}
                className="aspect-video w-full object-cover"
              />
            </CarouselThumbnail>
          ))}
        </CarouselThumbnails>
      </div>
    </Carousel>
  )
}

Vertical

orientation="vertical" nécessite une hauteur sur <CarouselContent />. Les boutons passent au-dessus et en dessous.

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const slides = [1, 2, 3, 4, 5]

export function CarouselVertical() {
  return (
    <div className="w-full max-w-xs py-12">
      <Carousel orientation="vertical" aria-label="Vertical">
        <CarouselContent className="h-52">
          {slides.map((value) => (
            <CarouselItem key={value} className="basis-1/2">
              <div className="flex h-full items-center justify-center rounded-xl border bg-card p-6 text-3xl font-semibold">
                {value}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  )
}

Contrôlé

Passez index et onIndexChange. Le balayage met à jour votre état et votre état fait défiler le carrousel.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
} from "@/components/ui/carousel"

const slides = [1, 2, 3, 4, 5]

export function CarouselControlled() {
  const [index, setIndex] = React.useState(2)

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex flex-wrap items-center gap-2">
        {slides.map((value, position) => (
          <Button
            key={value}
            size="sm"
            variant={position === index ? "secondary" : "outline"}
            onClick={() => setIndex(position)}
          >
            {value}
          </Button>
        ))}
        <span className="text-sm text-muted-foreground">index = {index}</span>
      </div>
      <Carousel index={index} onIndexChange={setIndex} aria-label="Controlled">
        <CarouselContent>
          {slides.map((value) => (
            <CarouselItem key={value}>
              <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
                {value}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <div className="mt-3 flex justify-center">
          <CarouselDots />
        </div>
      </Carousel>
    </div>
  )
}

Retour au début et index de départ

rewind renvoie Next de la dernière diapositive à la première. defaultIndex s’ouvre sur une diapositive sans animation de défilement.

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const slides = [1, 2, 3, 4, 5]

export function CarouselRewind() {
  return (
    <div className="w-full max-w-xs px-12">
      <Carousel rewind defaultIndex={2} aria-label="Rewind">
        <CarouselContent>
          {slides.map((value) => (
            <CarouselItem key={value}>
              <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
                {value}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  )
}

Faire glisser un lien avec la souris fait défiler sans l’ouvrir. Atteindre par tabulation une diapositive hors écran la fait défiler dans la vue.

import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
} from "@/components/ui/carousel"

const articles = Array.from({ length: 6 }, (_, index) => index + 1)

export function CarouselLinks() {
  return (
    <Carousel spacing="sm" aria-label="Articles" className="w-full max-w-md">
      <CarouselContent>
        {articles.map((article) => (
          <CarouselItem key={article} className="basis-4/5 sm:basis-1/2">
            <a
              href="#"
              className="flex h-32 flex-col justify-end gap-1 rounded-xl border bg-card p-4 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
            >
              <span className="text-sm font-medium">Article {article}</span>
              <span className="text-sm text-muted-foreground">
                Read the full story
              </span>
            </a>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="mt-3 flex justify-center">
        <CarouselDots />
      </div>
    </Carousel>
  )
}

Ajout et suppression de diapositives

Les points, le compteur et les boutons se mettent à jour à mesure que les diapositives apparaissent et disparaissent.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Carousel,
  CarouselContent,
  CarouselCounter,
  CarouselDots,
  CarouselItem,
} from "@/components/ui/carousel"

export function CarouselDynamic() {
  const [slides, setSlides] = React.useState([1, 2, 3])

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex gap-2">
        <Button
          size="sm"
          variant="outline"
          onClick={() => setSlides([...slides, slides.length + 1])}
        >
          Add slide
        </Button>
        <Button
          size="sm"
          variant="outline"
          disabled={slides.length === 0}
          onClick={() => setSlides(slides.slice(0, -1))}
        >
          Remove slide
        </Button>
      </div>
      <Carousel aria-label="Dynamic">
        <CarouselContent>
          {slides.map((value) => (
            <CarouselItem key={value} className="basis-1/2">
              <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
                {value}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <div className="mt-3 flex items-center justify-between">
          <CarouselCounter />
          <CarouselDots />
        </div>
      </Carousel>
    </div>
  )
}

Imbriqué

Les flèches, le glissement et les points ne déplacent que le carrousel dans lequel vous vous trouvez.

import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
} from "@/components/ui/carousel"

const outerSlides = [1, 2, 3]
const innerSlides = [1, 2, 3, 4, 5]

export function CarouselNested() {
  return (
    <Carousel aria-label="Outer" className="w-full max-w-md">
      <CarouselContent>
        {outerSlides.map((outer) => (
          <CarouselItem key={outer}>
            <div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
              <p className="text-sm font-medium">Outer slide {outer}</p>
              <Carousel spacing="sm" aria-label={`Inner ${outer}`}>
                <CarouselContent>
                  {innerSlides.map((inner) => (
                    <CarouselItem key={inner} className="basis-1/3">
                      <div className="flex aspect-square items-center justify-center rounded-lg bg-muted text-lg font-medium">
                        {outer}.{inner}
                      </div>
                    </CarouselItem>
                  ))}
                </CarouselContent>
                <div className="mt-2 flex justify-center">
                  <CarouselDots />
                </div>
              </Carousel>
            </div>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="mt-3 flex justify-center">
        <CarouselDots />
      </div>
    </Carousel>
  )
}

Contenu long et diapositive unique

Le texte sans coupure passe à la ligne dans sa diapositive. Avec une seule diapositive, les points sont masqués et les boutons restent désactivés.

import {
  Carousel,
  CarouselContent,
  CarouselCounter,
  CarouselDots,
  CarouselItem,
} from "@/components/ui/carousel"

export function CarouselLongContent() {
  return (
    <div className="flex w-full max-w-md flex-col gap-6">
      <Carousel aria-label="Long content">
        <CarouselContent>
          <CarouselItem className="basis-4/5">
            <div className="rounded-xl border bg-card p-4 text-sm wrap-anywhere">
              Supercalifragilisticexpialidocious_with_an_unbroken_string_that_never_ends_and_keeps_going_well_past_the_edge
            </div>
          </CarouselItem>
          <CarouselItem className="basis-4/5">
            <div className="rounded-xl border bg-card p-4 text-sm">
              مرحبا 你好 👩‍👩‍👧‍👦 A second slide with mixed scripts.
            </div>
          </CarouselItem>
        </CarouselContent>
        <div className="mt-3 flex justify-center">
          <CarouselDots />
        </div>
      </Carousel>
      <Carousel aria-label="Single slide">
        <CarouselContent>
          <CarouselItem>
            <div className="flex aspect-video items-center justify-center rounded-xl border bg-card p-6 text-2xl font-semibold">
              Only one
            </div>
          </CarouselItem>
        </CarouselContent>
        <div className="mt-3 flex items-center justify-between">
          <CarouselCounter />
          <CarouselDots />
        </div>
      </Carousel>
    </div>
  )
}

De droite à gauche

Les diapositives démarrent à droite, les flèches s’inversent, la flèche gauche fait avancer et les points se remplissent depuis la droite.

import {
  Carousel,
  CarouselContent,
  CarouselCounter,
  CarouselDots,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const slides = [1, 2, 3, 4, 5]

export function CarouselRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md px-12">
      <Carousel autoplay={{ delay: 4000 }} aria-label="شرائح">
        <CarouselContent>
          {slides.map((value) => (
            <CarouselItem key={value} className="basis-1/2">
              <div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
                {value}
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
        <div className="mt-3 flex items-center justify-between">
          <CarouselCounter />
          <CarouselDots />
        </div>
      </Carousel>
    </div>
  )
}

Les touches fonctionnent tant que le focus se trouve dans le carrousel, sauf dans les champs de texte et les carrousels imbriqués.

ToucheAction
→Diapositive suivante. Précédente dans les mises en page de droite à gauche. ↓ dans les carrousels verticaux.
←Diapositive précédente. Suivante dans les mises en page de droite à gauche. ↑ dans les carrousels verticaux.
TabParcourt les boutons, le point actif et le contenu des diapositives, en faisant défiler dans la vue les diapositives hors écran.
EnterSpaceActive le bouton, le point ou la miniature ayant le focus.
  • La racine est une region décrite comme un carrousel. Donnez-lui un aria-label.
  • Chaque élément est un group décrit comme une diapositive et étiqueté avec sa position, par exemple « 3 of 5 ».
  • Une région live polie annonce la nouvelle diapositive après une navigation au clavier ou aux boutons, et reste silencieuse pendant la lecture automatique.
  • Les points et les miniatures n’utilisent qu’un seul arrêt de tabulation, et le focus suit l’élément actif.
  • Previous et Next restent focusables lorsqu’ils sont désactivés : le focus n’est donc jamais perdu à l’une ou l’autre extrémité.
PropTypePar défaut
orientation
"horizontal" | "vertical""horizontal"
spacingÉcart entre les diapositives.
"none" | "sm" | "default" | "lg""default"
index
number–
defaultIndex
number0
onIndexChange
(index: number) => void–
rewindRevient de la dernière diapositive à la première.
booleanfalse
mouseDragPermet à la souris de faire glisser les diapositives.
booleantrue
autoplayAvance automatiquement selon un minuteur. delay vaut 5000 ms par défaut, 1000 ms au minimum.
boolean | { delay?: number }false
setApi
(api: CarouselApi) => void–
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="carousel"Ciblez la racine en CSS.
data-orientationL’orientation.
--carousel-spacingL’écart entre les diapositives, défini par spacing.
PropTypePar défaut
classNameAppliqué à la piste qui contient les diapositives.
string–
viewportClassNameAppliqué à la zone de défilement visible.
string–
AttributDescription
data-slot="carousel-content"La zone de défilement visible.
data-slot="carousel-container"La piste qu’elle contient.
data-scrollablePrésent lorsqu’il y a plus d’une position.
data-draggingPrésent pendant un glissement à la souris.
PropTypePar défaut
render
ReactElement | (props, state) => ReactElement<div>
AttributDescription
data-slot="carousel-item"Définissez basis-* pour en afficher plusieurs à la fois.

Les deux rendent un <Button /> et acceptent ses props. Ils se placent en dehors du contenu : laissez donc de la place autour du carrousel.

PropTypePar défaut
variant
ButtonProps["variant"]"outline"
size
ButtonProps["size"]"icon-sm"
childrenSuit l’orientation et la direction.
ReactNodeArrow icon
AttributDescription
data-slot="carousel-previous"Étiqueté « Previous slide ».
data-slot="carousel-next"Étiqueté « Next slide ».
data-disabledPrésent à l’une ou l’autre extrémité. Le bouton reste focusable.
PropTypePar défaut
aria-label
string"Choose slide"
AttributDescription
data-slot="carousel-dots"Le groupe de points. Masqué lorsqu’il n’y a qu’une position.
data-slot="carousel-dot"Chaque point. Le point actif a aria-current.
--dot-activeDe 0 à 1, degré d’activité d’un point pendant le défilement.
AttributDescription
data-slot="carousel-counter"Affiche la position actuelle sur le total avec un chiffre défilant.
PropTypePar défaut
variant
ButtonProps["variant"]"ghost"
size
ButtonProps["size"]"icon-sm"
AttributDescription
data-slot="carousel-autoplay-toggle"Étiqueté « Pause slideshow » ou « Play slideshow ».
PropTypePar défaut
aria-label
string"Slides"
AttributDescription
data-slot="carousel-thumbnails"La bande défilante.
PropTypePar défaut
indexLa diapositive qu’elle ouvre. Par défaut, sa position dans la bande.
number–
render
ReactElement | (props, state) => ReactElement<button>
AttributDescription
data-slot="carousel-thumbnail"Ciblez les miniatures en CSS.
data-activePrésent tant que sa diapositive est visible.

Retourné par setApi et useCarousel(). Passez jump: true pour se déplacer sans animation.

PropTypePar défaut
scrollPrev
(jump?: boolean) => void–
scrollNext
(jump?: boolean) => void–
scrollToDéfile jusqu’à une position d’accroche.
(index: number, jump?: boolean) => void–
scrollToSlideDéfile jusqu’à la position qui affiche une diapositive.
(slideIndex: number, jump?: boolean) => void–
canScrollPrev
() => boolean–
canScrollNext
() => boolean–
selectedScrollSnap
() => number–
scrollSnapList
() => number[]–
slidesInView
() => number[]–
slideNodes
() => HTMLElement[]–
viewportNode
() => HTMLElement | null–
play
() => void–
stop
() => void–
isPlaying
() => boolean–
on / off
(event: "select" | "scroll" | "settle" | "reInit", listener) => CarouselApi–

À utiliser dans <Carousel /> pour construire vos propres contrôles. Retourne api, orientation, selectedIndex, snapCount, slideCount, slidesInView, canScrollPrev, canScrollNext, isPlaying ainsi que les méthodes de défilement et de lecture.