Przejdź do treści

Carousel

Przewijany poziomo lub pionowo zestaw slajdów z nawigacją gestem przesunięcia, klawiaturą i przyciskami, zbudowany na Embla.

Kod źródłowyEmblaRejestr
01
TokenyJedno źródło prawdy
02
KomponentyDomyślnie dostępne
03
WzorceSprawdzone kompozycje
04
MotywyDopasowane do twojej marki
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const slides = [
  {
    title: "Tokeny",
    body: "Jedno źródło prawdy",
  },
  {
    title: "Komponenty",
    body: "Domyślnie dostępne",
  },
  {
    title: "Wzorce",
    body: "Sprawdzone kompozycje",
  },
  {
    title: "Motywy",
    body: "Dopasowane do twojej marki",
  },
]

export default function CarouselDemo() {
  return (
    <Carousel className="w-full max-w-44" aria-label="Najważniejsze cechy prfct">
      <CarouselContent>
        {slides.map((slide, index) => (
          <CarouselItem
            key={slide.title}
            aria-label={`${index + 1} z ${slides.length}`}
          >
            <div className="flex aspect-square flex-col justify-between rounded-xl bg-muted p-4">
              <span className="font-mono text-xs text-muted-foreground numeric">
                {String(index + 1).padStart(2, "0")}
              </span>
              <div className="flex flex-col">
                <span className="text-heading-md text-foreground">
                  {slide.title}
                </span>
                <span className="text-sm text-muted-foreground">
                  {slide.body}
                </span>
              </div>
            </div>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Anatomia

1
2
3
  1. 1WidokObszar widoczny. Przycina slajdy poza ekranem i podąża za przeciągnięciem lub przesunięciem palcem.
  2. 2PoprzedniCofa o jeden slajd. Na pierwszym slajdzie jest wyłączony, chyba że karuzela jest zapętlona.
  3. 3SlajdJeden slajd: grupa odczytywana jako slajd. Nadaj mu etykietę z jego pozycją.
  4. 4NastępnyPrzechodzi o jeden slajd dalej. Na ostatnim slajdzie jest wyłączony, chyba że karuzela jest zapętlona.

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/carousel.json

Użycie

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"
<Carousel aria-label="Product screenshots">
  <CarouselContent>
    <CarouselItem>…</CarouselItem>
    <CarouselItem>…</CarouselItem>
    <CarouselItem>…</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Przyciski „poprzedni” i „następny” znajdują się poza slajdami, 48 px na lewo i na prawo od nich. Zostaw na nie miejsce — albo ustaw je samodzielnie przez className.

Przykłady

Kilka slajdów naraz

Szerokość slajdu ustawisz klasą basis-* na CarouselItem. Połącz ją z responsywnymi prefiksami, żeby na szerszych ekranach pokazać więcej slajdów, oraz z opts={{ align: "start" }}, żeby slajdy przyciągały się do krawędzi początkowej.

FakturaPozycje, podatek i sumy
Dziennik zmianDatowane wpisy z tagami
CennikTrzy plany i FAQ
E-mail powitalnyPięcioetapowa seria powitalna
Strona statusuDostępność i historia incydentów
Informacje o wydaniuNowości, poprawki, aktualizacje
Wiki zespołuZagnieżdżone strony z wyszukiwarką
import {
  ActivityIcon,
  BookOpenIcon,
  MailIcon,
  ReceiptIcon,
  RocketIcon,
  ScrollTextIcon,
  TagIcon,
  type LucideIcon,
} from "lucide-react"

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

const templates: { name: string; description: string; icon: LucideIcon }[] = [
  {
    name: "Faktura",
    description: "Pozycje, podatek i sumy",
    icon: ReceiptIcon,
  },
  {
    name: "Dziennik zmian",
    description: "Datowane wpisy z tagami",
    icon: ScrollTextIcon,
  },
  {
    name: "Cennik",
    description: "Trzy plany i FAQ",
    icon: TagIcon,
  },
  {
    name: "E-mail powitalny",
    description: "Pięcioetapowa seria powitalna",
    icon: MailIcon,
  },
  {
    name: "Strona statusu",
    description: "Dostępność i historia incydentów",
    icon: ActivityIcon,
  },
  {
    name: "Informacje o wydaniu",
    description: "Nowości, poprawki, aktualizacje",
    icon: RocketIcon,
  },
  {
    name: "Wiki zespołu",
    description: "Zagnieżdżone strony z wyszukiwarką",
    icon: BookOpenIcon,
  },
]

export default function CarouselSizes() {
  return (
    // px-12 leaves room for the previous and next buttons, which sit outside the slides.
    <div className="w-full max-w-lg px-12">
      <Carousel opts={{ align: "start" }} aria-label="Szablony">
        <CarouselContent>
          {templates.map((template, index) => (
            <CarouselItem
              key={template.name}
              className="basis-1/2 sm:basis-1/3"
              aria-label={`${index + 1} z ${templates.length}`}
            >
              <Card size="sm" className="h-full">
                <CardContent className="flex h-full flex-col gap-6">
                  <div
                    className="flex size-9 items-center justify-center rounded-lg border bg-surface text-muted-foreground"
                    aria-hidden="true"
                  >
                    <template.icon className="size-4" />
                  </div>
                  <div className="mt-auto flex flex-col gap-0.5">
                    <span className="text-sm font-medium">{template.name}</span>
                    <span className="text-xs text-muted-foreground">
                      {template.description}
                    </span>
                  </div>
                </CardContent>
              </Card>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  )
}
<Carousel opts={{ align: "start" }}>
  <CarouselContent>
    <CarouselItem className="basis-1/2 sm:basis-1/3">…</CarouselItem>
  </CarouselContent>
</Carousel>

Odstępy

Slajdy rozdziela padding na CarouselItem i dopasowany do niego ujemny margines na CarouselContent (domyślnie 16 px). Żeby zmienić odstęp, zmień oba: -ml-2 na treści i pl-2 na każdym elemencie.

Pionowa

orientation="vertical" przewija w osi Y. Nadaj CarouselContent stałą wysokość; przyciski przeniosą się nad i pod slajdy.

09:00
Przegląd makietStudio A
10:30
Synchronizacja tokenówZdalnie
12:00
Lunch & learn: animacjeAtrium
14:00
Audyt dostępnościStudio B
16:30
Planowanie wydaniaZdalnie
import { Card, CardContent } from "@/components/ui/card"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const events = [
  { time: "09:00", title: "Przegląd makiet", room: "Studio A" },
  { time: "10:30", title: "Synchronizacja tokenów", room: "Zdalnie" },
  { time: "12:00", title: "Lunch & learn: animacje", room: "Atrium" },
  { time: "14:00", title: "Audyt dostępności", room: "Studio B" },
  { time: "16:30", title: "Planowanie wydania", room: "Zdalnie" },
]

export default function CarouselVertical() {
  return (
    <Carousel
      orientation="vertical"
      opts={{ align: "start" }}
      className="my-12 w-full max-w-xs"
      aria-label="Plan dnia"
    >
      <CarouselContent className="h-60">
        {events.map((event, index) => (
          <CarouselItem
            key={event.title}
            className="basis-1/3"
            aria-label={`${index + 1} z ${events.length}`}
          >
            <Card size="sm" className="py-3">
              <CardContent className="flex items-center gap-3">
                <span className="font-mono text-xs text-muted-foreground tabular-nums">
                  {event.time}
                </span>
                <div className="flex min-w-0 flex-col">
                  <span className="truncate text-sm font-medium">
                    {event.title}
                  </span>
                  <span className="text-xs text-muted-foreground">
                    {event.room}
                  </span>
                </div>
              </CardContent>
            </Card>
          </CarouselItem>
        ))}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Z API

setApi przekazuje ci instancję Embla. Użyj jej, żeby zbudować licznik slajdów, kropki paginacji albo miniatury i reagować na zdarzenia select.

01Pulpit
02Ustawienia
03Rozliczenia
04Aktywność
05Zespół
1 / 5
"use client"

import * as React from "react"

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

const shots = [
  { title: "Pulpit" },
  { title: "Ustawienia" },
  { title: "Rozliczenia" },
  { title: "Aktywność" },
  { title: "Zespół" },
]

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

  React.useEffect(() => {
    if (!api) return
    const onSelect = () => setCurrent(api.selectedScrollSnap())
    api.on("select", onSelect)
    api.on("reInit", onSelect)
    return () => {
      api.off("select", onSelect)
      api.off("reInit", onSelect)
    }
  }, [api])

  return (
    // px-12 leaves room for the previous and next buttons, which sit outside the slides.
    <div className="flex w-full max-w-md flex-col items-center gap-4 px-12">
      <Carousel
        setApi={setApi}
        className="w-full"
        aria-label="Zrzuty ekranu produktu"
      >
        <CarouselContent>
          {shots.map((shot, index) => (
            <CarouselItem
              key={shot.title}
              aria-label={`${index + 1} z ${shots.length}`}
            >
              <div className="flex aspect-[16/10] flex-col justify-between rounded-xl bg-muted p-5">
                <span className="font-mono text-xs text-muted-foreground numeric">
                  {String(index + 1).padStart(2, "0")}
                </span>
                <span className="text-heading-sm text-foreground">
                  {shot.title}
                </span>
              </div>
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
      <div className="flex items-center gap-4">
        <div className="flex">
          {shots.map((shot, index) => (
            // A 24px target around a 6px dot (WCAG 2.5.8).
            <button
              key={shot.title}
              type="button"
              aria-label={`Przejdź do slajdu ${index + 1}: ${shot.title}`}
              aria-current={index === current ? "true" : undefined}
              onClick={() => api?.scrollTo(index)}
              className="group/dot flex h-6 min-w-6 items-center justify-center rounded-full focus-ring-inset"
            >
              <span
                className={cn(
                  "h-1.5 rounded-full transition-[width,background-color] duration-base ease-standard",
                  index === current
                    ? "w-5 bg-foreground"
                    : "w-1.5 bg-gray-7 group-hover/dot:bg-gray-9"
                )}
              />
            </button>
          ))}
        </div>
        <span
          className="text-xs text-muted-foreground tabular-nums"
          aria-live="polite"
        >
          {current + 1} / {shots.length}
        </span>
      </div>
    </div>
  )
}
const [api, setApi] = React.useState<CarouselApi>()

React.useEffect(() => {
  if (!api) return
  const onSelect = () => setCurrent(api.selectedScrollSnap())
  api.on("select", onSelect)
  return () => {
    api.off("select", onSelect)
  }
}, [api])

<Carousel setApi={setApi}>…</Carousel>

Wytyczne

Kiedy używać

  • Do przeglądania zestawu równorzędnych elementów, gdy wystarczy widzieć kilka naraz: zrzutów ekranu, szablonów, zdjęć produktów, opinii klientów.
  • Na urządzeniach dotykowych, na których przesuwanie palcem po zestawie jest naturalne.

Kiedy nie używać

  • Do treści, którą każdy musi zobaczyć. Slajdy po pierwszym rzadko są oglądane — ważne informacje umieść bezpośrednio na stronie.
  • Do nawigacji lub porównywania — użyj komponentu Tabs, siatki albo komponentu Table.
  • Do pojedynczego obrazu w sekcji hero — po prostu pokaż ten obraz.

Pokaż, że jest więcej

Pozwól następnemu slajdowi wyglądać zza krawędzi, pokaż licznik albo kropki i zostaw przycisk „poprzedni” wyłączony na pierwszym slajdzie. Nikt nie powinien się zastanawiać, czy jest coś więcej.

1 / 4

DobrzeKontrolki i wskaźnik pozycji od razu pokazują, jak duży jest zestaw.
Następny slajd za 3 s…
ŹleSlajdy, które przewijają się same i nie mają kontrolek, zabierają treść, zanim ktoś skończy czytać.

Automatyczne przewijanie

prfct nie przewija slajdów automatycznie. Jeśli dodasz do Embla wtyczkę autoplay, wstrzymuj przewijanie po najechaniu kursorem i przy fokusie, dodaj widoczny przycisk pauzy i w ogóle nie włączaj automatycznego przewijania, gdy ustawione jest prefers-reduced-motion. Treść, która porusza się sama dłużej niż pięć sekund, musi dać się zatrzymać (WCAG 2.2.2).

Dostępność

Karuzela to region z aria-roledescription="carousel", a każdy slajd to group z aria-roledescription="slide". Oba opisy to wbudowane teksty, więc LocaleProvider je tłumaczy: w polskiej wersji językowej brzmią „karuzela” i „slajd”.

KlawiszDziałanie
Tab
Przenosi fokus do slajdów oraz na przyciski „poprzedni” i „następny”.
←
Przewija do poprzedniego slajdu, gdy fokus jest wewnątrz poziomej karuzeli.
→
Przewija do następnego slajdu, gdy fokus jest wewnątrz poziomej karuzeli.
↑↓
Przewijają do poprzedniego i następnego slajdu w pionowej karuzeli.
EnterSpacja
Aktywuje przycisk „poprzedni” lub „następny”, na którym jest fokus.
  • Strzałki zostają w polach. W polu tekstowym, obszarze tekstowym, liście wyboru i edytowalnym tekście strzałki przesuwają kursor, a nie slajdy.
  • Nazwij karuzelę. Nadaj Carousel atrybut aria-label, który mówi, co pokazują slajdy: „Zrzuty ekranu produktu”.
  • Oznacz slajdy pozycją. Nadaj każdemu CarouselItem atrybut aria-label, np. „2 z 5”, żeby użytkownik wiedział, w którym miejscu jest.
  • Przyciski mają etykiety. CarouselPrevious i CarouselNext zawierają dla czytników ekranu etykiety „Poprzedni slajd” i „Następny slajd” — tłumaczone przez LocaleProvider — a na krańcach są wyłączone, chyba że karuzela jest zapętlona.
  • Tekst na obrazach. Utrzymuj kontrast 4,5:1 dla tekstu na zdjęciach: umieść między obrazem a tekstem przyciemniającą nakładkę (scrim).

Dokumentacja API

Element główny. Przyjmuje wszystkie propsy div.

PropTypDomyślnie
opts

Opcje Embla: align, loop, dragFree, slidesToScroll, breakpoints i inne.

EmblaOptionsTypeBrak wartości domyślnej
plugins

Wtyczki Embla, np. autoplay albo gesty kółka myszy.

EmblaPluginType[]Brak wartości domyślnej
orientation

Oś przewijania. Karuzele pionowe potrzebują stałej wysokości treści.

"horizontal" | "vertical""horizontal"
setApi

Otrzymuje instancję Embla, gdy tylko jest gotowa.

(api: CarouselApi) => voidBrak wartości domyślnej

CarouselContent

Przewijana ścieżka. Przyjmuje wszystkie propsy div; jej ujemny margines razem z paddingiem elementów tworzy odstęp.

CarouselItem

Pojedynczy slajd, domyślnie na pełną szerokość. Ustaw basis-*, żeby pokazać kilka naraz.

CarouselPrevious, CarouselNext

Przyciski ikonowe w wariancie outline, podłączone do karuzeli. Przyjmują wszystkie propsy Button; domyślny variant to "outline", a size — "icon-sm".

useCarousel

Zwraca { api, scrollPrev, scrollNext, canScrollPrev, canScrollNext, orientation } do budowania własnych kontrolek wewnątrz Carousel.