HextaUI

Carousel

ネイティブのスクロールスナップによるスライドです。タッチでの慣性、マウスドラッグ、矢印キー、ドット、サムネイル、適切なタイミングで一時停止する自動再生に対応します。

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

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

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>

スライドは CSS のスクロールスナップでネイティブにスクロールするため、タッチの慣性やトラックパッドのスクロールがプラットフォームの感覚どおりになります。マウスでドラッグでき、矢印キーで 1 枚ずつ移動します。

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

API

setApi を渡してカルーセルの API を取得し、select をリッスンして独自の位置を表示します。最後のスライドでは Next が無効になりますが、フォーカスは保たれます。

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

1 画面に複数表示

各項目は独自の basis を設定します。余白は spacing prop で決まるため、どの 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>
  )
}

ドットとカウンター

アクティブなドットはスライドのスクロールに合わせて伸び、隣のドットは場所を空けます。カウンターは、変化した桁だけを回転させます。

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

自動再生

autoplay はデフォルトでオフで、モーションの低減が有効な場合もオフのままです。ホバー、キーボードフォーカス、タッチ、ドラッグ、非表示のタブ、画面外へのスクロールで一時停止し、タイマーの進行に合わせてアクティブなドットが満たされます。

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

サムネイル

<CarouselThumbnails /> はメインのカルーセルに追従し、アクティブなサムネイルが見えるようスクロールします。

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

垂直

orientation="vertical" には、<CarouselContent /> に高さの指定が必要です。ボタンは上下に移動します。

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

制御

index と onIndexChange を渡します。スワイプすると state が更新され、state の変更でカルーセルがスクロールします。

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

巻き戻しと開始インデックス

rewind は、最後のスライドで Next を押すと最初のスライドに戻します。defaultIndex は、スクロールアニメーションなしでスライドを開きます。

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

マウスでリンクをドラッグすると、リンクを開かずにスクロールします。画面外のスライドにタブで移動すると、そのスライドが表示位置までスクロールされます。

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

スライドの追加と削除

スライドの増減に合わせて、ドット、カウンター、ボタンが更新されます。

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

入れ子

矢印キー、ドラッグ、ドットは、操作中のカルーセルだけを動かします。

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

長いコンテンツと単一のスライド

区切りのないテキストはスライド内で折り返されます。スライドが 1 枚の場合、ドットは非表示になり、ボタンは無効のままです。

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

右から左

スライドは右から始まり、矢印が反転し、左矢印キーで前に進み、ドットは右から埋まります。

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

キーは、テキスト入力欄とネストしたカルーセルを除き、カルーセル内のどこにフォーカスがあっても動作します。

キーアクション
→次のスライド。右から左のレイアウトでは前のスライド。縦向きのカルーセルでは ↓。
←前のスライド。右から左のレイアウトでは次のスライド。縦向きのカルーセルでは ↑。
Tabボタン、アクティブなドット、スライド内のコンテンツの間を移動し、画面外のスライドは表示位置までスクロールされます。
EnterSpaceフォーカス中のボタン、ドット、サムネイルを実行します。
  • ルートは、カルーセルとして説明される region です。aria-label を付けてください。
  • 各項目は、スライドとして説明され、「3 of 5」のように位置のラベルが付く group です。
  • キーボードやボタンによる移動の後は、polite なライブリージョンが新しいスライドを通知し、自動再生の間は通知しません。
  • ドットとサムネイルは 1 つのタブストップを使い、フォーカスはアクティブなものに追従します。
  • Previous と Next は無効でもフォーカス可能なままなので、どちらの端でもフォーカスが失われません。
プロパティ型デフォルト
orientation
"horizontal" | "vertical""horizontal"
spacingスライド間の余白。
"none" | "sm" | "default" | "lg""default"
index
number–
defaultIndex
number0
onIndexChange
(index: number) => void–
rewind最後のスライドから最初のスライドに戻ります。
booleanfalse
mouseDragマウスでスライドをドラッグできるようにします。
booleantrue
autoplayタイマーで進めます。delay のデフォルトは 5000ms で、最小は 1000ms です。
boolean | { delay?: number }false
setApi
(api: CarouselApi) => void–
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="carousel"CSSでルートを指定します。
data-orientation向き。
--carousel-spacingspacing で設定されるスライド間の余白。
プロパティ型デフォルト
classNameスライドを保持するトラックに適用されます。
string–
viewportClassNameスクロールするビューポートに適用されます。
string–
属性説明
data-slot="carousel-content"スクロールするビューポート。
data-slot="carousel-container"その内側のトラック。
data-scrollable位置が 2 つ以上あるときに付与されます。
data-draggingマウスでのドラッグ中に付与されます。
プロパティ型デフォルト
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="carousel-item"basis-* を設定すると、1 画面に複数のスライドを表示できます。

どちらも <Button /> をレンダリングし、その props を受け付けます。コンテンツの外側に配置されるため、カルーセルの周囲に余白を確保してください。

プロパティ型デフォルト
variant
ButtonProps["variant"]"outline"
size
ButtonProps["size"]"icon-sm"
children向きと方向に従います。
ReactNodeArrow icon
属性説明
data-slot="carousel-previous"「Previous slide」というラベルが付きます。
data-slot="carousel-next"「Next slide」というラベルが付きます。
data-disabledどちらかの端で付与されます。ボタンはフォーカス可能なままです。
プロパティ型デフォルト
aria-label
string"Choose slide"
属性説明
data-slot="carousel-dots"ドットのグループ。位置が 1 つの場合は非表示になります。
data-slot="carousel-dot"各ドット。アクティブなものには aria-current が付きます。
--dot-active0 から 1。スクロール中のドットのアクティブ度。
属性説明
data-slot="carousel-counter"現在の位置と全体の数を、回転する数字で表示します。
プロパティ型デフォルト
variant
ButtonProps["variant"]"ghost"
size
ButtonProps["size"]"icon-sm"
属性説明
data-slot="carousel-autoplay-toggle"「Pause slideshow」または「Play slideshow」というラベルが付きます。
プロパティ型デフォルト
aria-label
string"Slides"
属性説明
data-slot="carousel-thumbnails"スクロールするストリップ。
プロパティ型デフォルト
index開くスライド。デフォルトは、ストリップ内での位置です。
number–
render
ReactElement | (props, state) => ReactElement<button>
属性説明
data-slot="carousel-thumbnail"CSS でサムネイルを指定します。
data-activeスライドが表示されている間、付与されます。

setApi と useCarousel() を通じて返されます。アニメーションなしで移動するには jump: true を渡します。

プロパティ型デフォルト
scrollPrev
(jump?: boolean) => void–
scrollNext
(jump?: boolean) => void–
scrollToスナップ位置までスクロールします。
(index: number, jump?: boolean) => void–
scrollToSlideスライドが表示される位置までスクロールします。
(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–

独自のコントロールを作るには <Carousel /> の内側で使います。api、orientation、selectedIndex、snapCount、slideCount、slidesInView、canScrollPrev、canScrollNext、isPlaying と、スクロールと再生のメソッドを返します。