Carousel
Przewijany poziomo lub pionowo zestaw slajdów z nawigacją gestem przesunięcia, klawiaturą i przyciskami, zbudowany na Embla.
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
- 1WidokObszar widoczny. Przycina slajdy poza ekranem i podąża za przeciągnięciem lub przesunięciem palcem.
- 2PoprzedniCofa o jeden slajd. Na pierwszym slajdzie jest wyłączony, chyba że karuzela jest zapętlona.
- 3SlajdJeden slajd: grupa odczytywana jako slajd. Nadaj mu etykietę z jego pozycją.
- 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.jsonUż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.
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.
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.
"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
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”.
| Klawisz | Dział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
Carouselatrybutaria-label, który mówi, co pokazują slajdy: „Zrzuty ekranu produktu”. - Oznacz slajdy pozycją. Nadaj każdemu
CarouselItematrybutaria-label, np. „2 z 5”, żeby użytkownik wiedział, w którym miejscu jest. - Przyciski mają etykiety.
CarouselPreviousiCarouselNextzawierają 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
Carousel
Element główny. Przyjmuje wszystkie propsy div.
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.