Display
Carousel.
A swipeable row of slides with previous and next buttons and dot indicators. Built on Embla.
North hub
Computer 42
Relay tower
Computer 17
Turtle yard
4 turtles
Archive
Computer 58
Arcade
Computer 9
import { CpuIcon, HardDriveIcon, MonitorIcon, RadioTowerIcon, ServerIcon } from "lucide-react";
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
const slides = [
{ title: "North hub", note: "Computer 42", icon: ServerIcon },
{ title: "Relay tower", note: "Computer 17", icon: RadioTowerIcon },
{ title: "Turtle yard", note: "4 turtles", icon: CpuIcon },
{ title: "Archive", note: "Computer 58", icon: HardDriveIcon },
{ title: "Arcade", note: "Computer 9", icon: MonitorIcon },
];
export default function CarouselDemo() {
return (
<div className="w-full max-w-xs px-12">
<Carousel aria-label="Hubs">
<CarouselContent>
{slides.map((s, i) => (
<CarouselItem key={s.title}>
<div className="overflow-hidden rounded-lg border bg-card">
<div className="flex aspect-[4/3] items-center justify-center border-b bg-accent bg-grid text-[#b7ca9e]">
<s.icon className="size-12 stroke-[1.25]" />
</div>
<div className="flex items-center justify-between p-4">
<div>
<div className="font-display text-base font-medium tracking-tight">{s.title}</div>
<div className="font-mono text-[10px] tracking-[0.16em] text-muted-foreground uppercase">{s.note}</div>
</div>
<span className="font-mono text-[10px] text-muted-foreground">
{String(i + 1).padStart(2, "0")}/{String(slides.length).padStart(2, "0")}
</span>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
</div>
);
}Installation#
With the CLI
$npx lanterncn add carouselThis 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
Install the dependencies
npm install embla-carousel-react lucide-reactCopy 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, };Update the import paths to match your project
The source imports
cnfrom@/lib/utilsand 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
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
const turtles = ["miner-01", "miner-02", "miner-03", "farmer-07", "builder-11", "scout-04"];
export default function CarouselSizes() {
return (
<div className="w-full max-w-md px-12">
<Carousel opts={{ align: "start" }} aria-label="Turtles">
<CarouselContent>
{turtles.map((t) => (
<CarouselItem key={t} className="basis-1/2 sm:basis-1/3">
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border bg-card p-3">
<span className="size-1.5 rounded-full bg-success" aria-hidden="true" />
<span className="truncate font-mono text-xs">{t}</span>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
</div>
);
}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.
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
const entries = [
["computer 17", "Hub looks great from spawn."],
["miner-02", "Left some coal in the chest."],
["computer 58", "Archive is back online."],
["farmer-07", "Wheat is ready by the north gate."],
["computer 9", "New high score on the arcade."],
];
export default function CarouselVertical() {
return (
<div className="w-full max-w-xs py-12">
<Carousel orientation="vertical" opts={{ align: "start" }} aria-label="Guestbook entries">
<CarouselContent className="h-[216px]">
{entries.map(([who, text]) => (
<CarouselItem key={who} className="basis-1/2">
<div className="flex h-full flex-col justify-center gap-1 rounded-lg border bg-card px-4">
<span className="font-mono text-[10px] tracking-[0.2em] text-success uppercase">{who}</span>
<span className="text-sm">{text}</span>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}