# Carousel

**When to use:** A slide track for stepping through a set of items one at a time, with arrows, dots, and drag/swipe support, plus optional autoplay. Slides are plain children, so any content works (images, cards, mixed content), not just a fixed image-gallery shape.

**Keywords:** slider, slideshow

**Import:** `import { Carousel } from '@ahrowe/ui'`
**Types:** `import type { CarouselProps } from '@ahrowe/ui'`

```tsx
import { Carousel } from '@ahrowe/ui';

// Uncontrolled, looping by default, with arrows, dots, and swipe all on
<Carousel>
  <div>Slide 1</div>
  <div>Slide 2</div>
  <div>Slide 3</div>
</Carousel>

// Controlled
const [index, setIndex] = useState(0);
<Carousel currentIndex={index} onChange={setIndex}>
  <div>Slide 1</div>
  <div>Slide 2</div>
  <div>Slide 3</div>
</Carousel>

// Autoplay, paused on hover/focus by default, with dots hidden
<Carousel autoPlay autoPlayInterval={4000} showDots={false}>
  <div>Slide 1</div>
  <div>Slide 2</div>
</Carousel>

// Bounded instead of looping: arrows disable at the first/last slide
<Carousel loop={false}>
  <div>Slide 1</div>
  <div>Slide 2</div>
</Carousel>

// Grow and shrink with the active slide instead of sizing every slide to the tallest one
<Carousel adaptiveHeight>
  <div>Short slide</div>
  <div>A much taller slide with a lot more content in it</div>
</Carousel>

// Only render the visible slides (plus the immediate neighbours) — for heavy slide content
<Carousel lazyRender>
  <HeavyChart />
  <HeavyChart />
  <HeavyChart />
</Carousel>

// No swipe/drag, arrows only
<Carousel swipeable={false} showDots={false}>
  <div>Slide 1</div>
  <div>Slide 2</div>
</Carousel>
```

Pass slides as direct children (an array or a set of sibling elements), not through a component that returns a fragment internally. `Carousel` reads its immediate children via `React.Children`, so a wrapper component's own children aren't visible to it. If slides come from a `.map()`, spread the array straight into `Carousel`, the same way `children` normally works in React.

**Interaction:** drag or swipe the track to move between slides (committing past roughly 20% of the container's width, or releasing further than that snaps to the next/previous slide; a shorter drag springs back).

**Keyboard and screen readers:** the dots are a tablist over the slides, so the whole set is one tab stop: `Tab` reaches the selected dot, left/right move between slides and carry focus with them, and `Home`/`End` jump to the first and last slide. Left/right also work from a focused arrow button. They deliberately do **not** work from inside a slide, so a text field or a scrollable region in a slide keeps its own arrow keys.

Pass an `aria-label` to make the carousel a landmark region a screen reader can jump to; without one it stays a plain group, since an unnamed landmark is noise:

```tsx
<Carousel aria-label="Featured products">
  <ProductCard />
  <ProductCard />
</Carousel>
```

**Many slides:** the dots never shrink to fit. Past the point where they no longer fit the carousel's width, the row becomes a scroll strip that keeps the selected dot centred and fades at whichever edge has more dots behind it, so the dots keep their size and their 24px hit area at any slide count. Every slide keeps its own dot, so keyboard navigation and screen reader output are the same at 3 slides and at 200.

Slides announce themselves as the panel each dot controls, or as "slide 2 of 5" when `showDots` is `false`. The track is an `aria-live="polite"` region so a slide change the user made is announced, and `aria-live="off"` under `autoPlay` so a timer does not interrupt them.

`autoPlay` pauses on hover and on focus, but there is no separate pause button, so an auto-rotating carousel does not on its own meet WCAG 2.2.2. Prefer `autoPlay` for decorative content, and leave it off where the slides carry information.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | The slides, one per direct child |
| `currentIndex` | `number` | Controlled active slide index |
| `defaultIndex` | `number` | Initial active slide index when uncontrolled (default `0`) |
| `onChange` | `(index: number) => void` | Called whenever the active slide changes, from arrows, dots, keyboard, drag, or autoplay |
| `loop` | `boolean` | Wrap from the last slide to the first and back (default `true`) |
| `autoPlay` | `boolean` | Automatically advance on a timer (default `false`) |
| `autoPlayInterval` | `number` | Autoplay delay in ms (default `4000`) |
| `pauseOnHover` | `boolean` | Pause autoplay while hovered or focused (default `true`) |
| `showArrows` | `boolean` | Show the prev/next buttons (default `true`); hidden automatically with one slide or none |
| `showDots` | `boolean` | Show the position dots (default `true`); hidden automatically with one slide or none |
| `swipeable` | `boolean` | Allow dragging/swiping the track (default `true`) |
| `adaptiveHeight` | `boolean` | Size the carousel to the active slide's height, animated over the same duration as the slide transition, instead of to the tallest slide (default `false`) |
| `lazyRender` | `boolean` | Only render the current slide and its immediate neighbours; the rest get no element at all, and a jump of more than three slides cuts straight to its slide (default `false`) |

**Height:** by default the track stretches every slide to the height of the tallest one, so the carousel keeps one fixed height. Set `adaptiveHeight` to have it follow the active slide's own height instead; the height animates over the same 0.4s as the slide transition, so a taller incoming slide is clipped until the two finish together. During a drag the height only follows once the new slide commits on release, so a drag between slides of very different heights clips for as long as the drag lasts.

**Lazy rendering:** `lazyRender` renders only the slide on show and its neighbours, and unmounts a slide once it is more than one position away, so the page holds the same few slides however large the set is. Slides a jump of up to three slides travels past stay mounted for the duration of the transition; a longer jump cuts straight to its slide instead, as sliding there would mount every slide in between. Pruning happens when the track's transform transition ends, so a consumer that disables the track transition in CSS keeps slides mounted rather than unmounting them. Unmounted slides lose their internal state (scroll position, uncontrolled inputs, media playback), which is why the flag is opt-in. Combine it with `adaptiveHeight` when slides differ in height, so the carousel sizes to the active slide rather than to whichever slides happen to be mounted.

**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ Carousel: { autoPlay: true } }}`. See [ConfigProvider.md](ConfigProvider.md).

**Slots:** `root` `track` `slide` `prevButton` `nextButton` `dots` `dot`

**Labels:** `previous` `next` `slides` `goToSlide(slide)` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `CarouselLabels`.
