Westy Design Systemv0.2.7
OverviewComponentsExamplesStorybook

Foundations

  • Typography
  • Colors
  • Spacing
  • Radius
  • Shadows
  • Icons

Components

  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Data Table
  • Date Picker
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Menubar
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Questionnaire
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sonner
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toggle
  • Toggle Group
  • Tooltip

Chat

  • Attachment
  • Bubble
  • Marker
  • Message
  • Message Scroller

Components

Carousel

A carousel with motion and swipe built using Embla.

Playground

1
2
3
4
5
—orientation
—CarouselItem className
—opts.align
—opts.loop
—CarouselPrevious / CarouselNext
Usage
Carousel wraps a CarouselContent with one CarouselItem per slide, plus optional CarouselPrevious / CarouselNext arrows. Slides can hold any markup — size the whole thing by constraining the Carousel itself. Swipe, drag, and arrow keys all scroll. Give Cards inside slides ring-inset: the scroll viewport clips exactly at the slide edge, which would cut off the Card's outer ring.
1
2
3
4
5
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@workspace/ui/components/carousel"

<Carousel className="w-full max-w-xs">
  <CarouselContent>
    {Array.from({ length: 5 }, (_, index) => (
      <CarouselItem key={index}>
        <Card className="py-0 ring-inset">
          <CardContent className="flex aspect-square items-center justify-center p-6">
            <span className="text-4xl font-medium">{index + 1}</span>
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
Sizes
Each CarouselItem is basis-full by default. Set a basis-* utility on the items to show more than one per view — responsive variants like md:basis-1/2 lg:basis-1/3 work too.
1
2
3
4
5
<Carousel opts={{ align: "start" }} className="w-full max-w-xs">
  <CarouselContent>
    {Array.from({ length: 5 }, (_, index) => (
      <CarouselItem key={index} className="basis-1/3">
        <Card className="py-0 ring-inset">
          <CardContent className="flex aspect-square items-center justify-center p-2">
            <span className="text-2xl font-medium">{index + 1}</span>
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
Spacing
The gap between slides is a negative margin on CarouselContent cancelled by padding on each CarouselItem — -ms-4 / ps-4 by default. Override both together, using logical properties (ms / ps) so RTL keeps working.
1
2
3
4
5
<Carousel opts={{ align: "start" }} className="w-full max-w-xs">
  <CarouselContent className="-ms-2">
    {Array.from({ length: 5 }, (_, index) => (
      <CarouselItem key={index} className="basis-1/3 ps-2">
        <Card className="py-0 ring-inset">
          <CardContent className="flex aspect-square items-center justify-center p-2">
            <span className="text-2xl font-medium">{index + 1}</span>
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
Orientation
orientation="vertical" stacks the slides and moves the arrows above and below the content. Give CarouselContent an explicit height — slides fill it according to their basis.
1
2
3
4
5
<Carousel
  orientation="vertical"
  opts={{ align: "start" }}
  className="w-full max-w-xs"
>
  <CarouselContent className="h-56">
    {Array.from({ length: 5 }, (_, index) => (
      <CarouselItem key={index} className="basis-1/2">
        <Card className="h-full py-0 ring-inset">
          <CardContent className="flex h-full items-center justify-center p-6">
            <span className="text-2xl font-medium">{index + 1}</span>
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
Options
The opts prop is forwarded to Embla — loop, align, dragFree, skipSnaps, and the rest of its options work as documented upstream. With loop the arrows never disable.
1
2
3
4
5
<Carousel opts={{ align: "start", loop: true }} className="w-full max-w-xs">
  <CarouselContent>
    {Array.from({ length: 5 }, (_, index) => (
      <CarouselItem key={index} className="basis-1/3">
        <Card className="py-0 ring-inset">
          <CardContent className="flex aspect-square items-center justify-center p-2">
            <span className="text-2xl font-medium">{index + 1}</span>
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
API
Pass a state setter to setApi to get the Embla CarouselApi instance, then subscribe to its events. Reading state like the selected slide belongs in useSyncExternalStore — the same pattern the component uses internally for the arrow buttons.
1
2
3
4
5
Slide 0 of 0
import {
  Carousel,
  type CarouselApi,
} from "@workspace/ui/components/carousel"

const [api, setApi] = React.useState<CarouselApi>()

const subscribe = React.useCallback(
  (onStoreChange: () => void) => {
    if (!api) return () => {}
    api.on("select", onStoreChange)
    api.on("reInit", onStoreChange)
    return () => {
      api.off("select", onStoreChange)
      api.off("reInit", onStoreChange)
    }
  },
  [api]
)

const current = React.useSyncExternalStore(
  subscribe,
  () => (api ? api.selectedScrollSnap() + 1 : 0),
  () => 0
)
const count = React.useSyncExternalStore(
  subscribe,
  () => api?.scrollSnapList().length ?? 0,
  () => 0
)

<Carousel setApi={setApi} className="w-full max-w-xs">
  {/* ... */}
</Carousel>
<div className="py-2 text-center text-sm text-muted-foreground">
  Slide {current} of {count}
</div>
RTL
The layout uses logical properties throughout, so dir="rtl" mirrors the arrows and their caret icons automatically. Embla needs to know too: pass direction: "rtl" in opts so dragging and scrolling reverse as well.
1
2
3
4
5
<Carousel
  dir="rtl"
  opts={{ direction: "rtl" }}
  className="w-full max-w-xs"
>
  <CarouselContent>
    {Array.from({ length: 5 }, (_, index) => (
      <CarouselItem key={index}>
        <Card className="py-0 ring-inset">
          <CardContent className="flex aspect-square items-center justify-center p-6">
            <span className="text-4xl font-medium">{index + 1}</span>
          </CardContent>
        </Card>
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
Accessibility
The root renders as a role="region" with aria-roledescription="carousel" and each slide as a role="group" with aria-roledescription="slide". Arrow keys (← / →) scroll while focus is inside, the arrow buttons carry sr-only labels and disable at the edges. Name the region yourself with aria-label when the page has more than one carousel.
<Carousel aria-label="Featured photos" className="w-full max-w-xs">
  <CarouselContent>
    <CarouselItem>{/* role="group" aria-roledescription="slide" */}</CarouselItem>
  </CarouselContent>
  {/* sr-only "Previous slide" / "Next slide" labels built in */}
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
API Reference
Five parts wrap the Embla Carousel hook: Carousel as the stateful root that owns the Embla instance, CarouselContent as the scroll container, CarouselItem for each slide, and CarouselPrevious / CarouselNext as ready-made icon Buttons wired to the instance. The plugins prop accepts Embla plugins (Autoplay, WheelGestures, …) once their packages are added, and the useCarousel hook exposes the context to custom parts. All parts accept their underlying element props.
ComponentPropDefault
Carouselopts?: CarouselOptions, plugins?: CarouselPlugin, setApi?: (api: CarouselApi) => void—
Carouselorientation?: "horizontal" | "vertical""horizontal"
CarouselItemclassName — basis-* controls slides per viewbasis-full
CarouselPreviousvariant?: Button variant, size?: Button size"outline" / "icon-sm"
CarouselNextvariant?: Button variant, size?: Button size"outline" / "icon-sm"
<Carousel data-slot="carousel" />
<CarouselContent data-slot="carousel-content" />
<CarouselItem data-slot="carousel-item" />
<CarouselPrevious data-slot="carousel-previous" />
<CarouselNext data-slot="carousel-next" />

Tokens used

8 design tokens consumed by the Carousel component.

  • --background

    Background

    bg-background
  • --foreground

    Foreground

    hover:text-foreground
  • --muted

    Muted

    hover:bg-muted
  • --border

    Border

    border-border
  • --input

    Input

    dark:border-inputdark:bg-input/30dark:hover:bg-input/50
  • --ring

    Ring

    focus-visible:border-ringfocus-visible:ring-ring/50
  • --text-sm

    Text Small (14px)

    text-sm
  • --spacing

    Spacing unit (4px)

    -ms-4ps-4-mt-4pt-4-start-12-end-12-top-12-bottom-12size-8