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">
                  &ldquo;{testimonial.quote}&rdquo;
                </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.

PropTypeDefault
optsobject
pluginsarray
orientationenum"horizontal"
setApifunction
classNamestring

Data attributeValues
[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.

PropTypeDefault
activebooleantrue
alignenum | function"center"
breakpointsobject{}
containScrollenum"trimSnaps"
containerstring | elementnull
directionenum"ltr"
dragFreebooleanfalse
dragThresholdnumber10
durationnumber25
inViewThresholdnumber0
loopbooleanfalse
skipSnapsbooleanfalse
slidesstring | elementsnull
slidesToScrollnumber | enum1
startIndexnumber0
watchDragboolean | functiontrue
watchResizeboolean | functiontrue
watchSlidesboolean | functiontrue
watchFocusboolean | functiontrue

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