Display

Carousel.

A swipeable row of slides with previous and next buttons and dot indicators. Built on Embla.

North hub
Computer 42
01/05
Relay tower
Computer 17
02/05
Turtle yard
4 turtles
03/05
Archive
Computer 58
04/05
Arcade
Computer 9
05/05

Installation#

With the CLI

$npx lanterncn add carousel

This also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/carousel. See Installation if your project is not set up yet.

Manually

  1. Install the dependencies

    npm install embla-carousel-react lucide-react
  2. Copy the source into your project

    components/ui/carousel.tsx
    "use client";
    
    import * as React from "react";
    import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
    import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
    
    import { cn } from "@/lib/utils";
    import { Button } from "@/components/ui/button";
    
    type CarouselApi = UseEmblaCarouselType[1];
    type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
    type CarouselOptions = UseCarouselParameters[0];
    type CarouselPlugin = UseCarouselParameters[1];
    
    type CarouselProps = {
      opts?: CarouselOptions;
      plugins?: CarouselPlugin;
      orientation?: "horizontal" | "vertical";
      setApi?: (api: CarouselApi) => void;
    };
    
    type CarouselContextProps = {
      carouselRef: ReturnType<typeof useEmblaCarousel>[0];
      api: ReturnType<typeof useEmblaCarousel>[1];
      scrollPrev: () => void;
      scrollNext: () => void;
      scrollTo: (index: number) => void;
      canScrollPrev: boolean;
      canScrollNext: boolean;
      selectedIndex: number;
      scrollSnaps: number[];
    } & CarouselProps;
    
    const CarouselContext = React.createContext<CarouselContextProps | null>(null);
    
    function useCarousel() {
      const context = React.useContext(CarouselContext);
      if (!context) {
        throw new Error("useCarousel must be used within a <Carousel />");
      }
      return context;
    }
    
    function Carousel({
      orientation = "horizontal",
      opts,
      setApi,
      plugins,
      className,
      children,
      ...props
    }: React.ComponentProps<"div"> & CarouselProps) {
      const [carouselRef, api] = useEmblaCarousel({ ...opts, axis: orientation === "horizontal" ? "x" : "y" }, plugins);
      const [canScrollPrev, setCanScrollPrev] = React.useState(false);
      const [canScrollNext, setCanScrollNext] = React.useState(false);
      const [selectedIndex, setSelectedIndex] = React.useState(0);
      const [scrollSnaps, setScrollSnaps] = React.useState<number[]>([]);
    
      const onSelect = React.useCallback((api: CarouselApi) => {
        if (!api) return;
        setCanScrollPrev(api.canScrollPrev());
        setCanScrollNext(api.canScrollNext());
        setSelectedIndex(api.selectedScrollSnap());
      }, []);
    
      const onReInit = React.useCallback(
        (api: CarouselApi) => {
          if (!api) return;
          setScrollSnaps(api.scrollSnapList());
          onSelect(api);
        },
        [onSelect],
      );
    
      const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);
      const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);
      const scrollTo = React.useCallback((index: number) => api?.scrollTo(index), [api]);
    
      const handleKeyDown = React.useCallback(
        (event: React.KeyboardEvent<HTMLDivElement>) => {
          const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
          const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
          if (event.key === prevKey) {
            event.preventDefault();
            scrollPrev();
          } else if (event.key === nextKey) {
            event.preventDefault();
            scrollNext();
          }
        },
        [orientation, scrollPrev, scrollNext],
      );
    
      React.useEffect(() => {
        if (!api || !setApi) return;
        setApi(api);
      }, [api, setApi]);
    
      React.useEffect(() => {
        if (!api) return;
        onReInit(api);
        api.on("reInit", onReInit);
        api.on("select", onSelect);
        return () => {
          api.off("reInit", onReInit);
          api.off("select", onSelect);
        };
      }, [api, onReInit, onSelect]);
    
      return (
        <CarouselContext.Provider
          value={{
            carouselRef,
            api,
            opts,
            orientation,
            scrollPrev,
            scrollNext,
            scrollTo,
            canScrollPrev,
            canScrollNext,
            selectedIndex,
            scrollSnaps,
          }}
        >
          <div
            onKeyDownCapture={handleKeyDown}
            className={cn("relative", className)}
            role="region"
            aria-roledescription="carousel"
            data-slot="carousel"
            data-orientation={orientation}
            {...props}
          >
            {children}
          </div>
        </CarouselContext.Provider>
      );
    }
    
    function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
      const { carouselRef, orientation } = useCarousel();
    
      return (
        <div ref={carouselRef} className="overflow-hidden" data-slot="carousel-content">
          <div className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)} {...props} />
        </div>
      );
    }
    
    function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
      const { orientation } = useCarousel();
    
      return (
        <div
          role="group"
          aria-roledescription="slide"
          data-slot="carousel-item"
          className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
          {...props}
        />
      );
    }
    
    function CarouselPrevious({
      className,
      variant = "outline",
      size = "icon-sm",
      ...props
    }: React.ComponentProps<typeof Button>) {
      const { orientation, scrollPrev, canScrollPrev } = useCarousel();
    
      return (
        <Button
          data-slot="carousel-previous"
          variant={variant}
          size={size}
          className={cn(
            "absolute bg-background hover:border-primary hover:text-primary",
            orientation === "horizontal" ? "top-1/2 -left-12 -translate-y-1/2" : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
            className,
          )}
          disabled={!canScrollPrev}
          onClick={scrollPrev}
          {...props}
        >
          <ArrowLeftIcon />
          <span className="sr-only">Previous slide</span>
        </Button>
      );
    }
    
    function CarouselNext({
      className,
      variant = "outline",
      size = "icon-sm",
      ...props
    }: React.ComponentProps<typeof Button>) {
      const { orientation, scrollNext, canScrollNext } = useCarousel();
    
      return (
        <Button
          data-slot="carousel-next"
          variant={variant}
          size={size}
          className={cn(
            "absolute bg-background hover:border-primary hover:text-primary",
            orientation === "horizontal" ? "top-1/2 -right-12 -translate-y-1/2" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
            className,
          )}
          disabled={!canScrollNext}
          onClick={scrollNext}
          {...props}
        >
          <ArrowRightIcon />
          <span className="sr-only">Next slide</span>
        </Button>
      );
    }
    
    /** One dot per snap point. The current slide's dot stretches into an orange bar. */
    function CarouselDots({ className, ...props }: React.ComponentProps<"div">) {
      const { scrollSnaps, selectedIndex, scrollTo, orientation } = useCarousel();
      if (scrollSnaps.length < 2) return null;
    
      return (
        <div
          data-slot="carousel-dots"
          className={cn(
            "flex items-center justify-center gap-1.5",
            orientation === "horizontal" ? "mt-4" : "flex-col",
            className,
          )}
          {...props}
        >
          {scrollSnaps.map((_, index) => {
            const selected = index === selectedIndex;
            return (
              <button
                key={index}
                type="button"
                aria-label={`Go to slide ${index + 1}`}
                aria-current={selected ? "true" : undefined}
                onClick={() => scrollTo(index)}
                className={cn(
                  "shrink-0 cursor-pointer rounded-full transition-all outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                  orientation === "horizontal" ? "h-1.5" : "w-1.5",
                  selected
                    ? cn("bg-primary", orientation === "horizontal" ? "w-5" : "h-5")
                    : cn("bg-input hover:bg-muted-foreground", orientation === "horizontal" ? "w-1.5" : "h-1.5"),
                )}
              />
            );
          })}
        </div>
      );
    }
    
    export {
      type CarouselApi,
      Carousel,
      CarouselContent,
      CarouselItem,
      CarouselPrevious,
      CarouselNext,
      CarouselDots,
      useCarousel,
    };
  3. Update the import paths to match your project

    The source imports cn from @/lib/utils and uses button.

Usage#

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
  CarouselDots,
  useCarousel,
} from "@/components/ui/carousel";
<Carousel>
  <CarouselContent>
    <CarouselItem>North hub</CarouselItem>
    <CarouselItem>Relay tower</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
  <CarouselDots />
</Carousel>

Set a basis class on CarouselItem, such as basis-1/3, to show several slides at once.

The previous and next buttons sit outside the slides, 3rem to each side. Leave room for them with padding on the parent, or move them with className.

Arrow keys move between slides when focus is inside the carousel. Use setApi to reach the Embla API.

Examples#

Sizes

miner-01
miner-02
miner-03
farmer-07
builder-11
scout-04

Vertical

computer 17Hub looks great from spawn.
miner-02Left some coal in the chest.
computer 58Archive is back online.
farmer-07Wheat is ready by the north gate.
computer 9New high score on the arcade.