Light

Carousel

A carousel with motion and swipe built using Embla.

Spec · from metadata

When to use

  • Showcasing a series of images or cards in a horizontal/vertical slider
  • Testimonials or feature highlights that cycle through items
  • Content overflow where horizontal scrolling with controls is preferred

When not to use

  • For tabbed content where all panels are equally important — use Tabs instead
  • For paginated lists — use Pagination instead
  • For a simple horizontally scrollable area — use ScrollArea instead

Anti-patterns

Avoid<Carousel><div>Slide 1</div><div>Slide 2</div></Carousel>
Prefer<Carousel><CarouselContent><CarouselItem>Slide 1</CarouselItem><CarouselItem>Slide 2</CarouselItem></CarouselContent></Carousel>

Items must be wrapped in CarouselContent > CarouselItem for Embla to track them

Avoid<Carousel opts={{ axis: "y" }}>
Prefer<Carousel orientation="vertical">

Use the orientation prop instead of setting axis directly in opts — the component maps orientation to axis internally

Accessibility

  • Required ARIAaria-roledescription="carousel" on rootaria-roledescription="slide" on each CarouselItem
  • Min touch target32px
  • Screen readerPrevious/Next buttons include sr-only text ('Previous slide', 'Next slide'); each item has role='group'

Token bindings

TokenCategoryUsage
pl-4 / pt-4spacingGap between carousel items (horizontal / vertical)

Import

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
} from "@timelycare/helix-ui"

Props

interface CarouselProps {
  orientation?: "horizontal" | "vertical"
  opts?: {
    align?: "start" | "center" | "end"
    loop?: boolean
    dragFree?: boolean
    // Additional Embla options
  }
  plugins?: EmblaPluginType[]
  setApi?: (api: EmblaCarouselType) => void
  className?: string
  children: React.ReactNode
}

interface CarouselItemProps {
  className?: string
  children: React.ReactNode
}

Variants

VariantUse ForTailwind
horizontalStandard carousels, image galleriesDefault orientation
verticalTestimonials, news feeds, sidebarsorientation="vertical"

Choosing a Variant

  • Horizontal: Most use cases - product showcases, image galleries
  • Vertical: Constrained vertical spaces, testimonial rotators

Styling

Arrow Buttons

PropertyValueTailwind
Size32×32pxsize-8
Backgroundbg-background#fafafa (light)
Border1px solidborder border-input
Border RadiusFull circlerounded-full
ShadowSubtleshadow-xs
PaddingCentered iconp-[10px]

Arrow Icon

PropertyValueTailwind
Size16×16pxsize-4
ColorPrimarytext-primary (#19518b)

CarouselItem (Card Container)

PropertyValueTailwind
Backgroundbg-cardwhite
Border1px solidborder border-border
Border Radius14pxrounded-xl
ShadowSubtleshadow-xs
OverflowHiddenoverflow-hidden

Layout

PropertyValueTailwind
Gap between items16pxgap-4
Item padding4pxp-1
Arrow offset (horizontal)-48px from content edgeleft-[-48px] / right-[-48px]
Arrow vertical center50%top-1/2 -translate-y-1/2

Description Text

PropertyValueTailwind
FontAdelle Sans Regularfont-normal
Size14pxtext-sm
Line Height20pxleading-5
Colortext-muted-foreground#646565
Padding8px verticalpy-2
AlignmentCentertext-center

Sizes

Control items per view with basis-* classes on CarouselItem:

Items VisibleTailwind on CarouselItem
1basis-full
2basis-1/2
3basis-1/3
Responsivebasis-full md:basis-1/2 lg:basis-1/3

States

Arrow Button States

StateBackgroundIconBorderImplementation
Defaultbg-backgroundtext-primaryborder-inputBase appearance
Hoverbg-accenttext-primaryborder-inputhover:bg-accent
Focusbg-backgroundtext-primaryborder-inputring-3 ring-ring rounded-full
Pressedbg-accentopacity-60border-inputactive:opacity-60
Disabledbg-backgroundopacity-50border-inputdisabled:opacity-50

Icons

Size: size-4 (16×16px)

IconUse For
ArrowLeftPrevious button (horizontal)
ArrowRightNext button (horizontal)
ArrowUpPrevious button (vertical)
ArrowDownNext button (vertical)

Common Patterns

Basic Carousel

<Carousel className="w-full max-w-xs">
  <CarouselContent>
    {Array.from({ length: 5 }).map((_, index) => (
      <CarouselItem key={index}>
        <Card className="border border-border rounded-xl shadow-xs overflow-hidden">
          <CardContent className="flex aspect-square items-center justify-center p-6">
            <span className="text-4xl font-semibold">{index + 1}</span>
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Multiple Items Per View

<Carousel opts={{ align: "start" }} className="w-full max-w-sm">
  <CarouselContent className="gap-4">
    {items.map((item, index) => (
      <CarouselItem key={index} className="p-1 md:basis-1/2 lg:basis-1/3">
        <Card className="border border-border rounded-xl shadow-xs overflow-hidden">
          <CardContent className="flex aspect-square items-center justify-center p-6">
            {item.content}
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Vertical Carousel

<Carousel orientation="vertical" className="w-full max-w-xs">
  <CarouselContent className="-mt-1 h-[200px]">
    {items.map((item, index) => (
      <CarouselItem key={index} className="pt-1 md:basis-1/2">
        <Card className="border border-border rounded-xl shadow-xs overflow-hidden">
          <CardContent className="flex items-center justify-center p-6">
            {item.content}
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

With Slide Counter

function CarouselWithCounter() {
  const [api, setApi] = useState<EmblaCarouselType>()
  const [current, setCurrent] = useState(0)
  const [count, setCount] = useState(0)

  useEffect(() => {
    if (!api) return
    setCount(api.scrollSnapList().length)
    setCurrent(api.selectedScrollSnap() + 1)
    api.on("select", () => setCurrent(api.selectedScrollSnap() + 1))
  }, [api])

  return (
    <Carousel setApi={setApi}>
      <CarouselContent>
        {/* items */}
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
      <div className="py-2 text-center text-sm text-muted-foreground">
        Slide {current} of {count}
      </div>
    </Carousel>
  )
}

With Images

<Carousel className="w-full max-w-md">
  <CarouselContent>
    {images.map((image, index) => (
      <CarouselItem key={index}>
        <Card className="border border-border rounded-xl shadow-xs overflow-hidden">
          <AspectRatio ratio={1}>
            <img
              src={image.src}
              alt={image.alt}
              className="absolute inset-0 size-full object-cover"
            />
          </AspectRatio>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Accessibility

  • Arrow buttons have accessible labels ("Previous slide" / "Next slide")
  • Disabled buttons at carousel bounds
  • Keyboard navigation via arrow keys
  • Consider pause-on-hover for auto-play carousels

Gotchas

ProblemSolution
Items not sizing correctlyAdd basis-* class to CarouselItem
Arrows positioned wrongCarousel needs relative positioning; arrows use absolute
Vertical carousel height wrongSet explicit height on CarouselContent
Need infinite loopAdd opts={{ loop: true }}
Drag snapping too strictAdd opts={{ dragFree: true }}
Arrow buttons not roundEnsure rounded-full is applied
Icon color wrongUse text-primary for icon color
Items have no gapAdd gap-4 to CarouselContent and p-1 to items

See Also

  • Related Components: Card (card carousel), Aspect Ratio (slide sizing)
  • Tokens: Colors — Theme-aware color tokens

Last updated: February 9, 2026