Carousel
A swipeable slideshow for cycling through images or content, with controls, indicators, and captions, built on Embla Carousel.
Default
A classic image carousel with overlay controls, bar indicators, and captions. Slides respond to drag, swipe, and arrow keys.
tsx
import { Carousel } from "@hummingbirdui/react";
const slides = [
{
src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=1200&auto=format&fit=crop",
title: "Misty peaks",
text: "Sunrise over the ridgeline after a night of rain.",
},
{
src: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=1200&auto=format&fit=crop",
title: "Into the forest",
text: "Light finding its way through the old pines.",
},
{
src: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1200&auto=format&fit=crop",
title: "Slow tides",
text: "An empty beach and a long afternoon.",
},
];
export default function CarouselDefault() {
return (
<Carousel className="mx-auto max-w-xl overflow-hidden rounded-xl">
<Carousel.Content>
{slides.map((slide) => (
<Carousel.Item key={slide.title}>
<img
src={slide.src}
alt={slide.title}
className="h-80 w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-t from-black/60 to-transparent" />
<Carousel.Caption>
<h5 className="mb-1 font-semibold">{slide.title}</h5>
<p className="mb-0 text-sm">{slide.text}</p>
</Carousel.Caption>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
<Carousel.Indicators />
</Carousel>
);
}Multiple slides
Slide widths come from basis utilities on Carousel.Item; opts forwards Embla options like align and loop.
tsx
import { Carousel, Badge } from "@hummingbirdui/react";
const destinations = [
{
src: "https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=800&auto=format&fit=crop",
name: "Dolomites",
country: "Italy",
},
{
src: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=800&auto=format&fit=crop",
name: "Lake Tahoe",
country: "USA",
},
{
src: "https://images.unsplash.com/photo-1469474968028-56623f02e42e?q=80&w=800&auto=format&fit=crop",
name: "Yosemite",
country: "USA",
},
{
src: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=800&auto=format&fit=crop",
name: "Highlands",
country: "Scotland",
},
{
src: "https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=800&auto=format&fit=crop",
name: "Provence",
country: "France",
},
];
export default function CarouselGallery() {
return (
<Carousel
opts={{ align: "start", loop: true }}
className="mx-auto max-w-2xl"
>
<Carousel.Content>
{destinations.map((destination) => (
<Carousel.Item
key={destination.name}
className="basis-2/3 sm:basis-1/2 md:basis-1/3"
>
<div className="relative overflow-hidden rounded-xl">
<img
src={destination.src}
alt={destination.name}
className="aspect-4/5 w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-linear-to-t from-black/70 to-transparent p-4 pt-10">
<p className="mb-1 font-semibold text-white">
{destination.name}
</p>
<Badge variant="subtle" color="warning">
{destination.country}
</Badge>
</div>
</div>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
</Carousel>
);
}Autoplay
The autoplay plugin can be passed through the plugins prop.
tsx
import { Carousel, Card, Avatar } from "@hummingbirdui/react";
import Autoplay from "embla-carousel-autoplay";
const testimonials = [
{
quote:
"The component API is so consistent that our team stopped reading the docs after the first week — everything just works the way you expect.",
name: "Amelia Chen",
role: "Frontend Lead, Nova Labs",
avatar: "https://i.pravatar.cc/80?img=47",
},
{
quote:
"We migrated our design system in a sprint. The CSS variables meant our brand theme dropped in without touching a single component.",
name: "Marcus Webb",
role: "Design Engineer, Fieldstone",
avatar: "https://i.pravatar.cc/80?img=12",
},
{
quote:
"Accessible by default, styled by our tokens, and the bundle barely moved. Exactly what a UI kit should be.",
name: "Priya Sharma",
role: "CTO, Brightline",
avatar: "https://i.pravatar.cc/80?img=32",
},
];
export default function CarouselAutoplay() {
return (
<Carousel
opts={{ loop: true }}
plugins={[Autoplay({ delay: 4000, stopOnInteraction: false })]}
className="mx-auto max-w-lg"
>
<Carousel.Content>
{testimonials.map((testimonial) => (
<Carousel.Item key={testimonial.name}>
<Card className="mx-1 my-1">
<Card.Body className="text-center">
<p className="mb-4 text-sm leading-relaxed">
“{testimonial.quote}”
</p>
<div className="flex items-center justify-center gap-3">
<Avatar>
<Avatar.Image
src={testimonial.avatar}
alt={testimonial.name}
/>
<Avatar.Fallback>{testimonial.name[0]}</Avatar.Fallback>
</Avatar>
<div className="text-start">
<p className="mb-0 text-sm font-semibold">
{testimonial.name}
</p>
<p className="mb-0 text-xs text-muted">
{testimonial.role}
</p>
</div>
</div>
</Card.Body>
</Card>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
);
}Drag free
dragFree: true scrolls with free momentum instead of snapping to slides.
Drag to scroll — free momentum, no snapping.
tsx
import { Carousel, Card } from "@hummingbirdui/react";
export default function CarouselDragFree() {
return (
<div className="mx-auto max-w-2xl">
<Carousel opts={{ dragFree: true, align: "start" }}>
<Carousel.Content className="cursor-grab active:cursor-grabbing">
{Array.from({ length: 10 }, (_, index) => (
<Carousel.Item
key={index}
className="basis-1/2 sm:basis-1/3 md:basis-1/4"
>
<Card className="aspect-square select-none">
<Card.Body className="flex items-center justify-center">
<span className="text-4xl font-semibold text-muted">
{index + 1}
</span>
</Card.Body>
</Card>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
<p className="mt-3 mb-0 text-center text-xs text-muted">
Drag to scroll — free momentum, no snapping.
</p>
</div>
);
}Vertical
orientation="vertical" scrolls along the y-axis; the container height is set on Carousel.Content.
tsx
import { Carousel } from "@hummingbirdui/react";
const images = [
{
src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=1200&auto=format&fit=crop",
alt: "Misty mountain peaks",
},
{
src: "https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=1200&auto=format&fit=crop",
alt: "Starry night over mountains",
},
{
src: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=1200&auto=format&fit=crop",
alt: "Sunlit forest",
},
{
src: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1200&auto=format&fit=crop",
alt: "Quiet beach",
},
];
export default function CarouselVertical() {
return (
<Carousel
orientation="vertical"
opts={{ loop: true }}
className="mx-auto max-w-sm overflow-hidden rounded-xl"
>
<Carousel.Content className="h-80 [--carousel-item-spacing:0px]">
{images.map((image) => (
<Carousel.Item key={image.src}>
<img
src={image.src}
alt={image.alt}
draggable={false}
className="h-full w-full select-none object-cover"
/>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
</Carousel>
);
}Slide counter
setApi exposes the Embla API for imperative control and state, such as tracking the selected slide.
Slide 1 of 3
tsx
import * as React from "react";
import { Carousel, type CarouselApi } from "@hummingbirdui/react";
const images = [
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1682687220742-aba13b6e50ba?q=80&w=1200&auto=format&fit=crop",
"https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=1200&auto=format&fit=crop",
];
export default function CarouselCounter() {
const [api, setApi] = React.useState<CarouselApi>();
const [current, setCurrent] = React.useState(1);
const [count, setCount] = React.useState(images.length);
React.useEffect(() => {
if (!api) return;
const update = () => {
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
};
update();
api.on("select", update);
api.on("reInit", update);
return () => {
api.off("select", update);
api.off("reInit", update);
};
}, [api]);
return (
<div className="mx-auto max-w-xl">
<Carousel setApi={setApi} className="overflow-hidden rounded-xl">
<Carousel.Content>
{images.map((src, index) => (
<Carousel.Item key={src}>
<img
src={src}
alt={`Slide ${index + 1}`}
className="h-72 w-full object-cover"
/>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
</Carousel>
<p className="mt-3 mb-0 text-center text-sm text-muted">
Slide {current} of {count}
</p>
</div>
);
}Thumbnails
The Embla API also drives external navigation - here a synced thumbnail strip highlights the selected slide and jumps on click.
tsx
import * as React from "react";
import { Carousel, type CarouselApi } from "@hummingbirdui/react";
const images = [
{
src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=1200&auto=format&fit=crop",
alt: "Misty mountain peaks",
},
{
src: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=1200&auto=format&fit=crop",
alt: "Sunlit forest",
},
{
src: "https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=1200&auto=format&fit=crop",
alt: "Starry night over mountains",
},
{
src: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1200&auto=format&fit=crop",
alt: "Lake at sunset",
},
{
src: "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1200&auto=format&fit=crop",
alt: "Quiet beach",
},
];
export default function CarouselThumbnails() {
const [api, setApi] = React.useState<CarouselApi>();
const [selected, setSelected] = React.useState(0);
React.useEffect(() => {
if (!api) return;
const onSelect = () => setSelected(api.selectedScrollSnap());
onSelect();
api.on("select", onSelect);
api.on("reInit", onSelect);
return () => {
api.off("select", onSelect);
api.off("reInit", onSelect);
};
}, [api]);
return (
<div className="mx-auto max-w-xl">
<Carousel setApi={setApi} className="overflow-hidden rounded-xl">
<Carousel.Content>
{images.map((image) => (
<Carousel.Item key={image.src}>
<img
src={image.src}
alt={image.alt}
className="h-72 w-full object-cover"
/>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
</Carousel>
<div className="mt-3 flex justify-center gap-2">
{images.map((image, index) => (
<button
key={image.src}
type="button"
onClick={() => api?.scrollTo(index)}
aria-label={`Go to slide ${index + 1}`}
aria-current={index === selected || undefined}
className={`overflow-hidden rounded-lg border-2 transition duration-200 ${
index === selected
? "border-primary"
: "border-transparent opacity-50 hover:opacity-100"
}`}
>
<img
src={image.src}
alt=""
className="h-12 w-16 object-cover sm:h-14 sm:w-20"
/>
</button>
))}
</div>
</div>
);
}API Reference
Built on Embla Carousel. The root owns the Embla instance and provides it to the parts; every Embla option and plugin passes through.
Carousel
| Prop | Type | Default |
|---|---|---|
opts | object | — |
plugins | array | — |
orientation | enum | "horizontal" |
setApi | function | — |
className | string | — |
| Data attribute | Values |
|---|---|
[data-orientation] | horizontal | vertical |
Options
Keys accepted by the opts object, matching the Embla v8 options one to one. axis is the exception — it is managed by the orientation prop and overwritten if set here.
| Prop | Type | Default |
|---|---|---|
active | boolean | true |
align | enum | function | "center" |
breakpoints | object | {} |
containScroll | enum | "trimSnaps" |
container | string | element | null |
direction | enum | "ltr" |
dragFree | boolean | false |
dragThreshold | number | 10 |
duration | number | 25 |
inViewThreshold | number | 0 |
loop | boolean | false |
skipSnaps | boolean | false |
slides | string | elements | null |
slidesToScroll | number | enum | 1 |
startIndex | number | 0 |
watchDrag | boolean | function | true |
watchResize | boolean | function | true |
watchSlides | boolean | function | true |
watchFocus | boolean | function | true |
Styling
The root reuses core Hummingbird's carousel class and its CSS variables for the controls, indicators, and caption; the Embla-specific parts add two variables of their own.
.carousel {
--carousel-control-width: 15%;
--carousel-control-color: var(--color-white);
--carousel-control-opacity: 0.5;
--carousel-control-hover-opacity: 0.9;
--carousel-control-icon-width: 2rem;
--carousel-control-icon-height: 2rem;
--carousel-indicator-width: 1.875rem;
--carousel-indicator-height: 0.1875rem;
--carousel-indicator-spacer: --spacing(0.75);
--carousel-indicator-active-bg: var(--color-white);
--carousel-indicator-hit-area-height: 0.625rem;
--carousel-indicator-opacity: 0.5;
--carousel-indicator-active-opacity: 1;
--carousel-caption-width: 70%;
--carousel-caption-spacer: --spacing(5);
--carousel-caption-padding-y: --spacing(5);
--carousel-caption-color: var(--color-white);
}
.carousel-container {
--carousel-item-spacing: --spacing(4);
}
.carousel-control {
--carousel-control-disabled-opacity: 0.25;
}