# Carousel (/ui/components/react/carousel)



<Playground component="carousel" />

## Usage [#usage]

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselPagination,
  CarouselProgress,
} from '@appica/ui-react/carousel'
```

```tsx
<Carousel loop>
  <CarouselContent>
    <CarouselSlide>Slide 1</CarouselSlide>
    <CarouselSlide>Slide 2</CarouselSlide>
    <CarouselSlide>Slide 3</CarouselSlide>
  </CarouselContent>
  <CarouselPrev
    render={
      <Button variant="outline" size="icon-md" className="rounded-full">
        <ChevronLeft />
      </Button>
    }
  />
  <CarouselNext
    render={
      <Button variant="outline" size="icon-md" className="rounded-full">
        <ChevronRight />
      </Button>
    }
  />
</Carousel>
```

`Carousel` wraps [Embla Carousel](https://www.embla-carousel.com) — a lightweight, dependency-free engine for dragging, snapping, and momentum — and exposes it as a small set of composable parts. `Carousel` sets up the engine and shares it through context; `CarouselContent` is the scroll viewport and track; each `CarouselSlide` is one snap point. Drop in `CarouselPrev` / `CarouselNext` for arrows and `CarouselPagination` or `CarouselProgress` for position feedback — each reads the engine from context, so they can sit anywhere inside the root.

Every slide is `basis-full` by default (one per view). Set a smaller `basis-*` on `CarouselSlide` to show several at once, and pick where they settle with `align`. Options that mirror Embla — `loop`, `align`, `slidesToScroll`, `dragFree`, `duration`, and more — are flat props on `Carousel`; the full option object is available through `options` as an escape hatch.

The arrows (`CarouselPrev` / `CarouselNext`) render an **unstyled** `<button>` by default so you own their look. Pass a [`Button`](/ui/components/react/button) (or any element) via `render` — put structural props like `variant`/`size` on that element, and use `position` to place the arrows inside, outside, or straddling the edge.

## Examples [#examples]

### Default [#default]

A single slide per view with arrows overlaid inside the frame and a pagination strip below. `loop` lets it wrap around from the last slide back to the first.

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselPagination,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5]

export default function CarouselDefault() {
  return (
    <div className="w-full max-w-xl pb-8">
      <Carousel loop>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n}>
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-3/2 w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
        <CarouselPagination className="absolute inset-x-0 top-full mt-5 justify-center" />
      </Carousel>
    </div>
  )
}
```

### Multiple slides (responsive) [#multiple-slides-responsive]

Give each `CarouselSlide` a fractional `basis-*` to fit several per view — here `basis-1/2` on small screens and `basis-1/3` above. `slidesToScroll="auto"` advances by however many slides are fully visible, and `align="start"` snaps them to the leading edge.

```tsx
import { Carousel, CarouselContent, CarouselSlide, CarouselPrev, CarouselNext } from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [4, 5, 6, 7, 8, 2]

export default function CarouselMultiple() {
  return (
    <div className="w-full max-w-xl px-15">
      <Carousel align="start" slidesToScroll="auto" loop>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="sm:basis-1/2 md:basis-1/3">
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-square w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          position="outside"
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          position="outside"
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
      </Carousel>
    </div>
  )
}
```

### Alignment [#alignment]

`align` controls where slides settle in the viewport — `start`, `center`, or `end`. Combined with a `basis` smaller than the viewport, `center` peeks the neighbouring slides on either side.

```tsx
import { Carousel, CarouselContent, CarouselSlide, CarouselPrev, CarouselNext } from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5, 6]

export default function CarouselAlignment() {
  return (
    <div className="w-full max-w-xl">
      <Carousel align="center" loop>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="basis-2/3">
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-3/2 w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
      </Carousel>
    </div>
  )
}
```

### Vertical [#vertical]

Set `orientation="vertical"` to drag and snap along the Y axis — same footprint as the horizontal carousel, just scrolling top-to-bottom. The viewport needs a fixed height (here `h-100` on `CarouselContent`); the arrows settle inside at the top and bottom edges automatically, and `CarouselPagination` takes `orientation="vertical"` to run its bullets down the side. The same 16px inter-slide gap as the horizontal carousel carries over, so slides don't butt together as you scroll.

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselPagination,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronUp, ChevronDown } from '@appica/icons-react'

const SLIDES = [1, 2, 4, 5]

export default function CarouselVertical() {
  return (
    <div className="w-full">
      <Carousel orientation="vertical" loop className="flex w-full items-center justify-center gap-5">
        <div className="relative w-full max-w-xl">
          <CarouselContent className="h-100">
            {SLIDES.map((n) => (
              <CarouselSlide key={n}>
                <img
                  src={`/carousel/slide-${n}.jpg`}
                  alt={`Slide ${n}`}
                  className="size-full rounded-xl object-cover"
                />
              </CarouselSlide>
            ))}
          </CarouselContent>
          <CarouselPrev
            render={
              <Button variant="outline" size="icon-md" className="rounded-full">
                <ChevronUp />
              </Button>
            }
          />
          <CarouselNext
            render={
              <Button variant="outline" size="icon-md" className="rounded-full">
                <ChevronDown />
              </Button>
            }
          />
        </div>
        <CarouselPagination orientation="vertical" />
      </Carousel>
    </div>
  )
}
```

### Autoplay [#autoplay]

Pass `autoplay` to advance on a timer via Embla's [Autoplay](https://www.embla-carousel.com/docs/plugins/autoplay) plugin. Give it an object to tune the `delay`, and add `resumeAfter` extension to restart the timer a set time after the user interacts. When autoplay is running, the active pagination bullet grows into a filling progress pill.

```tsx
import { Carousel, CarouselContent, CarouselSlide, CarouselPagination } from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 5, 4, 7, 6]

export default function CarouselAutoplay() {
  return (
    <div className="w-full max-w-xl pb-8">
      <Carousel loop autoplay={{ delay: 4000, resumeAfter: 3000 }}>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n}>
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-3/2 w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPagination className="absolute inset-x-0 top-full mt-5 justify-center" />
      </Carousel>
    </div>
  )
}
```

### Auto Scroll [#auto-scroll]

`autoScroll` swaps the discrete timer for continuous marquee-style motion via the [Auto Scroll](https://www.embla-carousel.com/docs/plugins/auto-scroll) plugin — pair it with `loop` and `dragFree` for an endless conveyor. Like `autoplay`, it accepts `resumeAfter` extension to restart the scroll a set time after the user interacts. `autoScroll` and `autoplay` are mutually exclusive. Auto Scroll pauses under `prefers-reduced-motion`.

```tsx
import { Carousel, CarouselContent, CarouselSlide } from '@appica/ui-react/carousel'

const SLIDES = [1, 2, 3, 4, 5, 6, 7, 8]

export default function CarouselAutoScroll() {
  return (
    <div className="w-full max-w-xl">
      <Carousel loop dragFree autoScroll={{ speed: 1.5, startDelay: 0, resumeAfter: 3000 }}>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="basis-1/2 sm:basis-1/3">
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-square w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
      </Carousel>
    </div>
  )
}
```

### Fade [#fade]

`fade` cross-fades between slides instead of sliding them, using the [Fade](https://www.embla-carousel.com/docs/plugins/fade) plugin. It's meant for one slide per view — ideal for a hero or a full-bleed gallery.

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselPagination,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5]

export default function CarouselFade() {
  return (
    <div className="w-full max-w-xl pb-8">
      <Carousel loop fade>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n}>
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-3/2 w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
        <CarouselPagination className="absolute inset-x-0 top-full mt-5 justify-center" />
      </Carousel>
    </div>
  )
}
```

### Auto Height [#auto-height]

`autoHeight` animates the viewport to each slide's natural height as you navigate, via the [Auto Height](https://www.embla-carousel.com/docs/plugins/auto-height) plugin — so mixed-length content doesn't clip or leave dead space. Use `align="start"` so slides measure from the top.

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselPagination,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const CARDS = [
  { title: 'Short', body: 'A compact slide.' },
  {
    title: 'Medium',
    body: 'This slide carries a couple of sentences, so it stands a little taller than the first. The viewport grows to fit it.',
  },
  {
    title: 'Tall',
    body: "And this one runs longer still. Auto Height animates the viewport between each slide's natural height as you navigate, so nothing is clipped and there's no empty space below shorter slides. It keeps mixed-length content — quotes, cards, forms — looking tidy inside a single carousel.",
  },
]

export default function CarouselAutoHeight() {
  return (
    <div className="w-full max-w-md">
      <Carousel loop autoHeight align="start">
        <CarouselContent>
          {CARDS.map((card) => (
            <CarouselSlide key={card.title}>
              <div className="border-border-muted bg-background-subtle rounded-xl border p-6">
                <h4 className="text-foreground-intense mb-2 font-semibold">{card.title}</h4>
                <p className="text-sm">{card.body}</p>
              </div>
            </CarouselSlide>
          ))}
        </CarouselContent>
        <div className="mt-5 flex items-center justify-between gap-4">
          <CarouselPrev
            position="none"
            render={
              <Button variant="outline" size="icon-md">
                <ChevronLeft />
              </Button>
            }
          />
          <CarouselPagination />
          <CarouselNext
            position="none"
            render={
              <Button variant="outline" size="icon-md">
                <ChevronRight />
              </Button>
            }
          />
        </div>
      </Carousel>
    </div>
  )
}
```

### Free drag & wheel gestures [#free-drag--wheel-gestures]

`dragFree` releases the snap points so the carousel glides to a momentum-based stop wherever the drag ends — a natural fit for a scrollable strip. Add `wheelGestures` to also scroll it with a trackpad or mouse wheel (horizontal swipes on a trackpad, vertical wheel on a mouse), via Embla's [Wheel Gestures](https://www.embla-carousel.com/docs/plugins/wheel-gestures) plugin.

```tsx
import { Carousel, CarouselContent, CarouselSlide } from '@appica/ui-react/carousel'

const SLIDES = [1, 2, 3, 4, 5, 6, 7, 8]

export default function CarouselDragFree() {
  return (
    <div className="w-full max-w-xl">
      <Carousel dragFree wheelGestures align="start" containScroll="trimSnaps">
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="basis-1/2 sm:basis-1/3 md:basis-1/4">
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-square w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
      </Carousel>
    </div>
  )
}
```

### Progress [#progress]

`CarouselProgress` renders a scroll progress bar (or a circular ring with `variant="circular"`). By default it tracks drag position, and automatically switches to counting down the autoplay timer while autoplay is playing.

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselProgress,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5, 6]

export default function CarouselProgressExample() {
  return (
    <div className="w-full max-w-xl">
      <Carousel align="start">
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="basis-1/2 sm:basis-1/3">
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-square w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <div className="mt-5 flex items-center justify-between">
          <CarouselProgress className="w-14" />
          <div className="flex gap-2">
            <CarouselPrev
              position="none"
              render={
                <Button variant="outline" size="icon-md">
                  <ChevronLeft />
                </Button>
              }
            />
            <CarouselNext
              position="none"
              render={
                <Button variant="outline" size="icon-md">
                  <ChevronRight />
                </Button>
              }
            />
          </div>
        </div>
      </Carousel>
    </div>
  )
}
```

### Thumbnails [#thumbnails]

Two carousels kept in sync: a main viewer and a strip of thumbnails. The `useLinkedCarousels(mainApi, thumbsApi)` hook wires each one's `select` event to the other, so navigating either drives both. Grab each engine with `setApi`, and read `selectedIndex` / `scrollTo` from `useCarousel()` inside a thumbnail to highlight and jump.

```tsx
'use client'

import * as React from 'react'
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  useCarousel,
  useLinkedCarousels,
  type CarouselApi,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5, 6, 7]

export default function CarouselThumbnails() {
  const [mainApi, setMainApi] = React.useState<CarouselApi>()
  const [thumbsApi, setThumbsApi] = React.useState<CarouselApi>()
  useLinkedCarousels(mainApi, thumbsApi)

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <Carousel loop setApi={setMainApi}>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n}>
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-3/2 w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
      </Carousel>

      <Carousel setApi={setThumbsApi} align="start" containScroll="keepSnaps">
        <CarouselContent className="-ms-2">
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="basis-1/4 sm:basis-1/5">
              <Thumb index={n - 1} src={`/carousel/slide-${n}.jpg`} alt={`Thumbnail ${n}`} />
            </CarouselSlide>
          ))}
        </CarouselContent>
      </Carousel>
    </div>
  )
}

function Thumb({ index, src, alt }: { index: number; src: string; alt: string }) {
  const { selectedIndex, scrollTo } = useCarousel()
  const isActive = index === selectedIndex
  return (
    <button
      type="button"
      onClick={() => scrollTo(index)}
      aria-label={`Go to slide ${index + 1}`}
      aria-current={isActive}
      className={`block w-full cursor-pointer overflow-hidden rounded-md border-2 transition-all duration-300 outline-none motion-reduce:transition-none ${isActive ? 'border-primary opacity-100' : 'border-transparent opacity-50 hover:opacity-80'}`}
    >
      <img src={src} alt={alt} className="aspect-square w-full object-cover" />
    </button>
  )
}
```

### Synced carousels [#synced-carousels]

`useLinkedCarousels` isn't limited to thumbnails — it links any two engines. Here a **sliding** image carousel drives a **fading** text carousel below it, so the caption cross-fades in as the image slides. The link is bidirectional, so the outside arrows move both engines in lockstep. Give the text carousel `fade` and `draggable={false}` so it only ever follows the images.

```tsx
'use client'

import * as React from 'react'
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  useLinkedCarousels,
  type CarouselApi,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [
  {
    n: 1,
    title: 'Sunlit summit',
    body: 'First light catches the ridgeline while the valley below stays in shadow — the clearest window for the summit push.',
  },
  {
    n: 2,
    title: 'Deer in bloom',
    body: 'A herd grazes the high meadow at golden hour, wildflowers running all the way to the treeline.',
  },
  {
    n: 3,
    title: 'Turquoise cove',
    body: 'Sea stacks shelter a quiet lagoon where the water shifts from pale sand to deep aquamarine.',
  },
  {
    n: 4,
    title: 'Rose plains',
    body: 'An abandoned homestead sits alone in a sea of pink grass, wide sky stretching to the horizon.',
  },
  {
    n: 5,
    title: 'Mirror lake',
    body: 'A lone peak doubles in glassy water as birds drift across a calm afternoon sky.',
  },
]

export default function CarouselSynced() {
  const [imagesApi, setImagesApi] = React.useState<CarouselApi>()
  const [textApi, setTextApi] = React.useState<CarouselApi>()
  useLinkedCarousels(imagesApi, textApi)

  return (
    <div className="flex w-full max-w-xl flex-col gap-4 px-15">
      <Carousel loop setApi={setImagesApi}>
        <CarouselContent>
          {SLIDES.map((slide) => (
            <CarouselSlide key={slide.n}>
              <img
                src={`/carousel/slide-${slide.n}.jpg`}
                alt={slide.title}
                className="aspect-3/2 w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          position="outside"
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          position="outside"
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
      </Carousel>

      <Carousel loop fade draggable={false} setApi={setTextApi}>
        <CarouselContent>
          {SLIDES.map((slide) => (
            <CarouselSlide key={slide.n}>
              <div className="min-h-24 text-center">
                <p className="text-foreground-intense text-lg font-semibold">{slide.title}</p>
                <p className="text-foreground mt-1 text-sm">{slide.body}</p>
              </div>
            </CarouselSlide>
          ))}
        </CarouselContent>
      </Carousel>
    </div>
  )
}
```

### Parallax [#parallax]

A creative effect built on the engine's `scroll` event: each slide's inner layer is over-scaled and translated against the scroll, so the image drifts within its frame as you drag. Grab the api with `setApi`, then tween `slideNodes()` on every `scroll` — accounting for loop points from `internalEngine()`.

```tsx
'use client'

import * as React from 'react'
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  type CarouselApi,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5, 6]
const PARALLAX_FACTOR = 1.7

export default function CarouselParallax() {
  const [api, setApi] = React.useState<CarouselApi>()

  React.useEffect(() => {
    if (!api) return
    const layers = api.slideNodes().map((slide) => slide.querySelector<HTMLElement>('[data-parallax-layer]'))

    const tween = (_api: CarouselApi, event?: { type: string }) => {
      const engine = api.internalEngine()
      const scrollProgress = api.scrollProgress()
      const slidesInView = api.slidesInView()
      const isScroll = event?.type === 'scroll'

      api.snapList().forEach((snap, snapIndex) => {
        let diffToTarget = snap - scrollProgress
        const slidesInSnap = engine.scrollSnapList.slidesBySnap[snapIndex] ?? []

        slidesInSnap.forEach((slideIndex) => {
          if (isScroll && !slidesInView.includes(slideIndex)) return
          if (engine.options.loop) {
            engine.slideLooper.loopPoints.forEach((loopItem) => {
              const target = loopItem.target()
              if (slideIndex === loopItem.index && target !== 0) {
                const sign = Math.sign(target)
                if (sign === -1) diffToTarget = snap - (1 + scrollProgress)
                if (sign === 1) diffToTarget = snap + (1 - scrollProgress)
              }
            })
          }
          const translate = diffToTarget * (-1 * PARALLAX_FACTOR) * 100
          const layer = layers[slideIndex]
          if (layer) layer.style.transform = `translateX(${translate}%)`
        })
      })
    }

    tween(api)
    api.on('scroll', tween)
    api.on('slidefocus', tween)
    api.on('reinit', tween)
    return () => {
      api.off('scroll', tween)
      api.off('slidefocus', tween)
      api.off('reinit', tween)
    }
  }, [api])

  return (
    <div className="w-full max-w-xl">
      <Carousel loop align="center" setApi={setApi}>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="basis-2/3">
              <div className="aspect-3/2 overflow-hidden rounded-xl">
                <div data-parallax-layer className="size-full">
                  <img
                    src={`/carousel/slide-${n}.jpg`}
                    alt={`Slide ${n}`}
                    className="size-full scale-[1.6] object-cover"
                  />
                </div>
              </div>
            </CarouselSlide>
          ))}
        </CarouselContent>
        <div className="mt-5 flex justify-center gap-2">
          <CarouselPrev
            position="none"
            render={
              <Button variant="outline" size="icon-md">
                <ChevronLeft />
              </Button>
            }
          />
          <CarouselNext
            position="none"
            render={
              <Button variant="outline" size="icon-md">
                <ChevronRight />
              </Button>
            }
          />
        </div>
      </Carousel>
    </div>
  )
}
```

### Scale [#scale]

The same `scroll`-driven tweening, applied to `transform: scale` and `opacity`: each slide shrinks and dims in proportion to its distance from the centre, so the focused slide pops. Pairs well with `align="center"` and a `basis` that peeks the neighbours.

```tsx
'use client'

import * as React from 'react'
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  type CarouselApi,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5, 6]
const TWEEN_FACTOR = 1.1

const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)

export default function CarouselScale() {
  const [api, setApi] = React.useState<CarouselApi>()

  React.useEffect(() => {
    if (!api) return
    const nodes = api.slideNodes().map((slide) => slide.querySelector<HTMLElement>('[data-scale-layer]'))

    const tween = (_api: CarouselApi, event?: { type: string }) => {
      const engine = api.internalEngine()
      const scrollProgress = api.scrollProgress()
      const slidesInView = api.slidesInView()
      const isScroll = event?.type === 'scroll'

      api.snapList().forEach((snap, snapIndex) => {
        let diffToTarget = snap - scrollProgress
        const slidesInSnap = engine.scrollSnapList.slidesBySnap[snapIndex] ?? []

        slidesInSnap.forEach((slideIndex) => {
          if (isScroll && !slidesInView.includes(slideIndex)) return
          if (engine.options.loop) {
            engine.slideLooper.loopPoints.forEach((loopItem) => {
              const target = loopItem.target()
              if (slideIndex === loopItem.index && target !== 0) {
                const sign = Math.sign(target)
                if (sign === -1) diffToTarget = snap - (1 + scrollProgress)
                if (sign === 1) diffToTarget = snap + (1 - scrollProgress)
              }
            })
          }
          const tweenValue = 1 - Math.abs(diffToTarget * TWEEN_FACTOR)
          const scale = clamp(tweenValue, 0.58, 1)
          const opacity = clamp(tweenValue, 0.24, 1)
          const node = nodes[slideIndex]
          if (node) {
            node.style.transform = `scale(${scale})`
            node.style.opacity = `${opacity}`
            node.style.transformOrigin = diffToTarget > 0 ? 'left center' : diffToTarget < 0 ? 'right center' : 'center'
          }
        })
      })
    }

    tween(api)
    api.on('scroll', tween)
    api.on('slidefocus', tween)
    api.on('reinit', tween)
    return () => {
      api.off('scroll', tween)
      api.off('slidefocus', tween)
      api.off('reinit', tween)
    }
  }, [api])

  return (
    <div className="w-full max-w-xl">
      <Carousel loop align="center" setApi={setApi}>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n} className="basis-2/3">
              <div data-scale-layer className="transition-none">
                <img
                  src={`/carousel/slide-${n}.jpg`}
                  alt={`Slide ${n}`}
                  className="aspect-3/2 w-full rounded-xl object-cover"
                />
              </div>
            </CarouselSlide>
          ))}
        </CarouselContent>
        <div className="mt-5 flex justify-center gap-2">
          <CarouselPrev
            position="none"
            render={
              <Button variant="outline" size="icon-md">
                <ChevronLeft />
              </Button>
            }
          />
          <CarouselNext
            position="none"
            render={
              <Button variant="outline" size="icon-md">
                <ChevronRight />
              </Button>
            }
          />
        </div>
      </Carousel>
    </div>
  )
}
```

### Overlaid controls [#overlaid-controls]

Set `light` on `CarouselPagination` / `CarouselProgress` (and a `light`-variant [`Button`](/ui/components/react/button) for the arrows) when the controls sit over imagery, so they read against a dark scrim instead of the page background.

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselPagination,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [
  { n: 1, title: 'Sunlit summit', caption: 'Aurora Range' },
  { n: 2, title: 'Deer in bloom', caption: 'Meadowvale Alps' },
  { n: 3, title: 'Turquoise cove', caption: 'Azure Isles' },
  { n: 4, title: 'Rose plains', caption: 'Crimson Flats' },
  { n: 5, title: 'Mirror lake', caption: 'Stillwater Valley' },
]

export default function CarouselLight() {
  return (
    <div className="w-full max-w-xl">
      <Carousel loop className="overflow-hidden rounded-xl">
        <CarouselContent>
          {SLIDES.map((slide) => (
            <CarouselSlide key={slide.n}>
              <div className="relative aspect-3/2 w-full">
                <img src={`/carousel/slide-${slide.n}.jpg`} alt={slide.title} className="size-full object-cover" />
                <div className="absolute inset-0 bg-linear-to-t from-black/70 via-black/10 to-transparent" />
                <div className="absolute inset-s-0 bottom-0 p-5">
                  <p className="text-lg font-semibold text-white">{slide.title}</p>
                  <p className="text-sm text-white/70">{slide.caption}</p>
                </div>
              </div>
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          render={
            <Button variant="light" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          render={
            <Button variant="light" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
        <CarouselPagination light className="absolute inset-x-0 bottom-5 justify-center" />
      </Carousel>
    </div>
  )
}
```

### Controlled with the api [#controlled-with-the-api]

Capture the Embla instance with `setApi` to drive the carousel imperatively and mirror its state elsewhere. Here external buttons call `goToPrev()` / `goToNext()` and a counter subscribes to the `select` event to show the current position.

```tsx
'use client'

import * as React from 'react'
import { Carousel, CarouselContent, CarouselSlide, type CarouselApi } from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5]

export default function CarouselControlled() {
  const [api, setApi] = React.useState<CarouselApi>()
  const [current, setCurrent] = React.useState(0)
  const [count, setCount] = React.useState(0)

  React.useEffect(() => {
    if (!api) return
    setCount(api.snapList().length)
    setCurrent(api.selectedSnap())
    const onSelect = () => setCurrent(api.selectedSnap())
    api.on('select', onSelect)
    return () => {
      api.off('select', onSelect)
    }
  }, [api])

  return (
    <div className="w-full max-w-xl">
      <Carousel loop setApi={setApi}>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n}>
              <img
                src={`/carousel/slide-${n}.jpg`}
                alt={`Slide ${n}`}
                className="aspect-3/2 w-full rounded-xl object-cover"
              />
            </CarouselSlide>
          ))}
        </CarouselContent>
      </Carousel>
      <div className="mt-5 flex items-center justify-between">
        <Button variant="outline" size="icon-md" aria-label="Previous slide" onClick={() => api?.goToPrev()}>
          <ChevronLeft />
        </Button>
        <p className="text-sm tabular-nums">
          Slide {current + 1} of {count}
        </p>
        <Button variant="outline" size="icon-md" aria-label="Next slide" onClick={() => api?.goToNext()}>
          <ChevronRight />
        </Button>
      </div>
    </div>
  )
}
```

### Next.js Image [#nextjs-image]

Render slides with `next/image` for automatic optimization. Give each slide a fixed-aspect wrapper and use `fill` with a matching `sizes` so the image resolves the right source; mark the first slide `priority` since it's the LCP candidate.

```tsx
import Image from 'next/image'
import {
  Carousel,
  CarouselContent,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselPagination,
} from '@appica/ui-react/carousel'
import { Button } from '@appica/ui-react/button'
import { ChevronLeft, ChevronRight } from '@appica/icons-react'

const SLIDES = [1, 2, 3, 4, 5]

export default function CarouselNextImage() {
  return (
    <div className="w-full max-w-xl pb-8">
      <Carousel loop>
        <CarouselContent>
          {SLIDES.map((n) => (
            <CarouselSlide key={n}>
              <div className="relative aspect-3/2 w-full overflow-hidden rounded-xl">
                <Image
                  src={`/carousel/slide-${n}.jpg`}
                  alt={`Slide ${n}`}
                  fill
                  sizes="(max-width: 640px) 100vw, 576px"
                  className="object-cover"
                  priority={n === 1}
                />
              </div>
            </CarouselSlide>
          ))}
        </CarouselContent>
        <CarouselPrev
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronLeft />
            </Button>
          }
        />
        <CarouselNext
          render={
            <Button variant="outline" size="icon-md" className="rounded-full">
              <ChevronRight />
            </Button>
          }
        />
        <CarouselPagination className="absolute inset-x-0 top-full mt-5 justify-center" />
      </Carousel>
    </div>
  )
}
```

## RTL [#rtl]

Every Appica UI component supports right-to-left layouts out of the box. Set the `dir` attribute on a container (commonly your `<html>` element) so CSS logical properties resolve correctly, and wrap your tree in `DirectionProvider` so direction-aware behavior follows the same direction.

```tsx
import { DirectionProvider } from '@appica/ui-react/providers/direction-provider'

export default function RootLayout({ children }) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <DirectionProvider dir="rtl">{children}</DirectionProvider>
      </body>
    </html>
  )
}
```

A horizontal carousel reverses its drag axis and starting edge, so slides flow from the right and the Previous/Next arrows swap sides. Give the arrow buttons direction-appropriate icons and `aria-label`s. Vertical carousels are unaffected by direction. For setup details and caveats, see the [RTL guide](/ui/docs/react/rtl).

<RtlPreview component="carousel" />

## API reference [#api-reference]

`Carousel` is a compound component. The root creates the [Embla](https://www.embla-carousel.com) engine and shares it through context; the parts read from it. Flat props on the root mirror [Embla options](https://www.embla-carousel.com/api/options/) and toggle its official plugins.

### Carousel [#carousel]

The root. Creates the engine, provides context, and renders a `<div role="region">`.

| Prop             | <ColMinWidth width="210">Type</ColMinWidth>                           | Default        | <ColMinWidth width="220">Description</ColMinWidth>                                              |
| ---------------- | --------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
| `orientation`    | <Code>'horizontal' \| 'vertical'</Code>                               | `'horizontal'` | Scroll axis. Maps to Embla's `axis`; exposed as `data-orientation`.                             |
| `loop`           | `boolean`                                                             | `false`        | Wrap around from the last slide to the first.                                                   |
| `align`          | <Code>'start' \| 'center' \| 'end'</Code>                             | `'start'`      | Where slides settle within the viewport.                                                        |
| `slidesToScroll` | <Code>number \| 'auto'</Code>                                         | `1`            | How many slides advance per step. `'auto'` groups by how many fit the viewport.                 |
| `containScroll`  | <Code>false \| 'trimSnaps' \| 'keepSnaps'</Code>                      | `'trimSnaps'`  | Clamp scrolling so there's no empty space at the edges. `false` disables containment.           |
| `dragFree`       | `boolean`                                                             | —              | Release snap points; glide to a momentum stop.                                                  |
| `startSnap`      | `number`                                                              | —              | Index of the slide to start on.                                                                 |
| `active`         | `boolean`                                                             | `true`         | When `false`, the engine is inert (no drag/snap) — e.g. to disable at a breakpoint.             |
| `draggable`      | `boolean`                                                             | `true`         | Whether pointer dragging is enabled (Embla's `watchDrag`).                                      |
| `duration`       | `number`                                                              | —              | Scroll animation duration (Embla's ease). Forced to `0` under reduced motion.                   |
| `options`        | <Code>EmblaOptionsType</Code>                                         | —              | Escape hatch: a raw Embla options object, merged last so it wins over the flat props.           |
| `autoplay`       | <Code>boolean \| AutoplayOptions & \{ resumeAfter?: number }</Code>   | —              | Enable the Autoplay plugin. `resumeAfter` (ms) restarts the timer after user interaction.       |
| `autoScroll`     | <Code>boolean \| AutoScrollOptions & \{ resumeAfter?: number }</Code> | —              | Enable continuous Auto Scroll. Mutually exclusive with `autoplay`; paused under reduced motion. |
| `autoHeight`     | <Code>boolean \| AutoHeightOptions</Code>                             | —              | Enable the Auto Height plugin; animates the viewport to each slide's height.                    |
| `fade`           | <Code>boolean \| FadeOptions</Code>                                   | —              | Enable the Fade plugin (cross-fade instead of slide). Best with one slide per view.             |
| `classNames`     | <Code>boolean \| ClassNamesOptions</Code>                             | —              | Enable the Class Names plugin, which toggles `snapped` / `inView` classes on slides.            |
| `accessibility`  | <Code>boolean \| AccessibilityOptions</Code>                          | `true`         | Keyboard/ARIA plugin for the viewport. Set `false` to opt out.                                  |
| `wheelGestures`  | <Code>boolean \| WheelGesturesOptions</Code>                          | `false`        | Enable trackpad / mouse-wheel scrolling.                                                        |
| `plugins`        | <Code>EmblaPluginType\[]</Code>                                       | —              | Additional Embla plugins to append.                                                             |
| `setApi`         | <Code>(api: CarouselApi) => void</Code>                               | —              | Receives the Embla instance once initialized — for imperative control.                          |
| `onReInit`       | <Code>(api: CarouselApi) => void</Code>                               | —              | Fires on init and whenever the engine re-initializes.                                           |
| `onSelect`       | <Code>(api: CarouselApi) => void</Code>                               | —              | Fires when the selected snap changes.                                                           |
| `onScroll`       | <Code>(api: CarouselApi) => void</Code>                               | —              | Fires continuously while scrolling.                                                             |
| `light`          | `boolean`                                                             | `false`        | Hint stored in context for light-on-dark surfaces; read via `useCarousel()`.                    |
| `aria-label`     | `string`                                                              | `'Carousel'`   | Accessible name for the region (native attribute, forwarded to the root).                       |
| `className`      | `string`                                                              | —              | Extra classes on the root, merged via `tailwind-merge`.                                         |

The root also forwards remaining native `<div>` attributes.

### CarouselContent [#carouselcontent]

The scroll viewport plus the flex track that holds the slides. Renders two nested `<div>`s. `className` lands on the inner track — a common place to set the viewport **height** for vertical carousels (e.g. `h-72`).

| Prop        | <ColMinWidth width="200">Type</ColMinWidth> | Default | <ColMinWidth width="220">Description</ColMinWidth>      |
| ----------- | ------------------------------------------- | ------- | ------------------------------------------------------- |
| `className` | `string`                                    | —       | Extra classes on the track. Also forwards native attrs. |
| `children`  | `ReactNode`                                 | —       | The `CarouselSlide`s.                                   |

### CarouselSlide [#carouselslide]

One slide / snap point. Renders a `<div role="group">` with `basis-full` by default — override `basis-*` to change how many slides show per view.

| Prop        | <ColMinWidth width="200">Type</ColMinWidth> | Default | <ColMinWidth width="220">Description</ColMinWidth>              |
| ----------- | ------------------------------------------- | ------- | --------------------------------------------------------------- |
| `className` | `string`                                    | —       | Extra classes — set `basis-*` here. Also forwards native attrs. |

### CarouselPrev / CarouselNext [#carouselprev--carouselnext]

The previous/next arrows. Each renders an **unstyled** `<button>` (auto-disabled at the ends) inside a positioner. Pass your own element — usually a [`Button`](/ui/components/react/button) — through `render`.

| Prop        | <ColMinWidth width="210">Type</ColMinWidth>                    | Default    | <ColMinWidth width="220">Description</ColMinWidth>                                                    |
| ----------- | -------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
| `position`  | <Code>'inside' \| 'outside' \| 'outside-half' \| 'none'</Code> | `'inside'` | Where the arrow sits relative to the viewport. `none` drops absolute positioning so you can place it. |
| `disabled`  | `boolean`                                                      | —          | Force-disable, on top of the automatic end-of-track disabling.                                        |
| `render`    | <Code>ReactElement \| ((props, state) => ReactElement)</Code>  | —          | Element to render as the arrow. `state` exposes `{ disabled, direction }`.                            |
| `className` | `string`                                                       | —          | Classes on the **positioner** (not the button). Put button styling on the `render` element.           |

Composition (`render`, `className`) is powered by [Base UI's `useRender`](https://base-ui.com/react/utils/use-render). Remaining button attributes (`onClick`, `aria-label`, …) are forwarded to the button and merged with the internal scroll handler.

### CarouselPagination [#carouselpagination]

A row (or column) of clickable bullets, one per snap point. Renders a `<div role="tablist">`; returns `null` when there's a single snap. The active bullet fills as an autoplay timer indicator while autoplay is playing.

| Prop          | <ColMinWidth width="200">Type</ColMinWidth> | Default        | <ColMinWidth width="220">Description</ColMinWidth> |
| ------------- | ------------------------------------------- | -------------- | -------------------------------------------------- |
| `orientation` | <Code>'horizontal' \| 'vertical'</Code>     | `'horizontal'` | Lay the bullets out in a row or a column.          |
| `light`       | `boolean`                                   | `false`        | Light-on-dark styling for use over imagery.        |
| `className`   | `string`                                    | —              | Extra classes. Also forwards native attrs.         |

### CarouselProgress [#carouselprogress]

A scroll/autoplay progress indicator. Renders a bar by default, or a ring with `variant="circular"`.

| Prop          | <ColMinWidth width="200">Type</ColMinWidth>   | Default        | <ColMinWidth width="220">Description</ColMinWidth>                                                    |
| ------------- | --------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `source`      | <Code>'auto' \| 'autoplay' \| 'scroll'</Code> | `'auto'`       | What drives the fill. `'auto'` counts down autoplay while it plays, otherwise tracks scroll position. |
| `variant`     | <Code>'bar' \| 'circular'</Code>              | `'bar'`        | Straight bar or circular ring.                                                                        |
| `orientation` | <Code>'horizontal' \| 'vertical'</Code>       | `'horizontal'` | Fill direction for the bar variant.                                                                   |
| `light`       | `boolean`                                     | `false`        | Light-on-dark styling for use over imagery.                                                           |
| `className`   | `string`                                      | —              | Extra classes. Also forwards native attrs.                                                            |

### useCarousel() [#usecarousel]

Hook that returns the carousel context from inside a `Carousel`: `api` (the Embla instance), navigation callbacks (`scrollPrev`, `scrollNext`, `scrollTo`), and reactive state (`selectedIndex`, `scrollSnaps`, `canScrollPrev`, `canScrollNext`, `orientation`, `direction`, plus autoplay state). Per-frame scroll progress is exposed as a `subscribeScrollProgress` / `getScrollProgress` pair (subscribe with `useSyncExternalStore` so only readers re-render — this is what `CarouselProgress` uses); read slides in view from `api.slidesInView()`. Throws if used outside a `Carousel`.

### useLinkedCarousels(mainApi, thumbsApi) [#uselinkedcarouselsmainapi-thumbsapi]

Bidirectionally syncs two carousels' selected snaps — the classic main-plus-thumbnails pattern. Wires each engine's `select` event to `goTo` the other. Both apis may be `undefined` initially (while waiting on `setApi`); the effect re-runs once both resolve.

## Accessibility [#accessibility]

* The root is a `role="region"` with `aria-roledescription="carousel"` and an `aria-label` (native attribute, default `"Carousel"`); each slide is a `role="group"` with `aria-roledescription="slide"`.
* The Accessibility plugin (on by default) makes the viewport keyboard-operable — focus it and use the arrow keys to navigate.
* `CarouselPrev` / `CarouselNext` carry `aria-label`s (`"Previous slide"` / `"Next slide"`) and are `disabled` at the ends of a non-looping carousel. When you supply a `render` element, keep an accessible name and localize it as needed.
* `CarouselPagination` is a `role="group"` labelled `"Choose slide to display"` of bullet `<button>`s, each labelled `"Go to slide N"` with `aria-current` on the active one.
* Progress fills, the autoplay indicator, and scroll animations honour `prefers-reduced-motion` — drag snaps instantly and Auto Scroll is disabled.
* Give image slides meaningful `alt` text (or `alt=""` for purely decorative imagery).
