# Carousel Component

A compound component for user-navigated slide carousels. Built on [embla-carousel](https://www.embla-carousel.com/), it exposes a composable set of sub-components that all communicate via a shared React context.

## Import path

```tsx
import Carousel, {
  CarouselTrack,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselDots,
  CarouselCounter,
} from '@hubspot/cms-component-library/Carousel';
```

## When to use Carousel vs Ticker

Use **Carousel** when:
- Content is finite and users need to navigate through individual slides (testimonials, product images, team member cards, hero variants)
- Users may want to pause on a specific slide to read or interact with it
- You need pagination controls (prev/next buttons, dot indicators, slide counter)
- The relationship between slides matters — each is a discrete piece of content

Use **Ticker** instead when:
- Content should scroll continuously with no user navigation (logo strips, announcement banners, partner lists)
- The animation is ambient / decorative and users don't need to interact with individual items

## Island Requirement

**Carousel must be rendered inside an island.** It uses `useState`, `useEffect`, `useCallback`, and the Embla Carousel browser API — none of which can run during server-side rendering. Placing `<Carousel>` directly in a module's `Component` export will fail.

The correct pattern is:
1. Create an `islands/` directory in your module folder
2. Put all `Carousel` usage inside an island file (e.g. `islands/CarouselIsland.tsx`) with a default export
3. In the module's `index.tsx`, import the island with the `?island` suffix and render it via `<Island module={...} />`

```tsx
// modules/MyModule/islands/CarouselIsland.tsx
import Carousel, { CarouselTrack, CarouselSlide, CarouselPrev, CarouselNext, CarouselDots } from '@hubspot/cms-component-library/Carousel';

const CarouselIsland = ({ slides }) => (
  <Carousel>
    <CarouselTrack>
      {slides.map((slide, i) => (
        <CarouselSlide key={i}>{slide.content}</CarouselSlide>
      ))}
    </CarouselTrack>
    <CarouselPrev />
    <CarouselNext />
    <CarouselDots />
  </Carousel>
);

export default CarouselIsland;
```

```tsx
// modules/MyModule/index.tsx
import { Island } from '@hubspot/cms-components';
// @ts-expect-error -- ?island not typed
import CarouselIsland from './islands/CarouselIsland.js?island';

export const Component = ({ slides }) => (
  <Island module={CarouselIsland} slides={slides} />
);
```

## Component Structure

```
Carousel/
├── index.tsx                  # Provider component + all named exports
├── types.ts                   # TypeScript type definitions
├── context.ts                 # CarouselContext + useCarouselContext hook
├── Track/
│   ├── index.tsx              # Embla viewport — controls scroll behavior
│   └── index.module.scss
├── Slide/
│   ├── index.tsx              # Wrapper for a single slide
│   └── index.module.scss
├── Prev/
│   ├── index.tsx              # Previous button with default icon
│   └── index.module.scss
├── Next/
│   ├── index.tsx              # Next button with default icon
│   └── index.module.scss
├── Dots/
│   ├── index.tsx              # Dot indicators with click-to-navigate
│   └── index.module.scss
└── Counter/
    ├── index.tsx              # "1 / 5" style slide counter
    └── index.module.scss
```

## Components

### Carousel (Provider)

**Purpose:** Root context provider. Manages shared state (`emblaApi`, `currentIndex`, `slideCount`, `canScrollPrev`, `canScrollNext`). All other sub-components must be descendants of `<Carousel>`.

**Props:**
```tsx
{
  children: React.ReactNode;
}
```

### CarouselTrack

**Purpose:** The scrollable viewport. Initializes `embla-carousel-react`, sets up autoplay (disabled in the HubSpot editor), and syncs scroll state into context. This is the only required sub-component — all `CarouselSlide` elements go inside it.

**Props:**
```tsx
{
  loop?: boolean;                         // Whether to loop at the ends (default: true)
  perPage?: number;                       // Slides visible at once (default: 1)
  gap?: number | string;                  // Gap between slides — number = px, string = any CSS unit
  align?: 'start' | 'center' | 'end';    // Slide alignment within the viewport (default: 'start')
  slidesToScroll?: 'auto' | number;       // How many slides to advance per scroll (default: 1)
  dragFree?: boolean;                     // Freeform drag (disabled in editor) (default: false)
  autoplay?: boolean;                     // Auto-advance slides (disabled in editor) (default: false)
  autoplayInterval?: number;              // Milliseconds between auto-advances (default: 3000)
  className?: string;
  style?: React.CSSProperties;
  children: React.ReactNode;             // Should be <CarouselSlide> elements
}
```

**Editor behavior:** When rendered inside the HubSpot editor, `dragFree` and `watchDrag` are both forced to `false`, and autoplay plugins are not attached. This keeps slides stable for editing.

**Reduced motion:** When `prefers-reduced-motion: reduce` is set, slide transition duration is set to `0`.

**CSS variables set on the viewport element:**
- `--carousel-slide-size`: `${100 / perPage}%` — controls each slide's flex-basis
- `--carousel-slide-spacing`: derived from `gap` — controls slide padding

### CarouselSlide

**Purpose:** Wrapper for a single slide's content. The outer `div` handles the Embla layout (flex-basis, min-width, padding). The inner `div` receives `className` and `style` for content styling.

**Props:**
```tsx
{
  className?: string;          // Applied to the inner content div
  style?: React.CSSProperties; // Applied to the inner content div
  children: React.ReactNode;
}
```

### CarouselPrev

**Purpose:** Button to scroll to the previous slide. Disabled when `canScrollPrev` is `false`. Renders a default left-arrow SVG icon; pass `children` to replace it with custom content.

**Props:**
```tsx
{
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;  // Replaces the default arrow icon
}
```

### CarouselNext

**Purpose:** Button to scroll to the next slide. Disabled when `canScrollNext` is `false`. Renders a default right-arrow SVG icon; pass `children` to replace it with custom content.

**Props:**
```tsx
{
  className?: string;
  style?: React.CSSProperties;
  children?: React.ReactNode;  // Replaces the default arrow icon
}
```

### CarouselDots

**Purpose:** A row of dot buttons, one per slide. The active dot is the same size as inactive dots but filled with a darker color. Each dot scrolls directly to its corresponding slide on click.

**Props:**
```tsx
{
  className?: string;
  style?: React.CSSProperties;
}
```

**CSS variables (override on parent or via `style`):**
- `--carousel-dot-size`: dot diameter (default: `8px`) — applies to all dots
- `--carousel-dot-color`: inactive dot color (default: `#d1d5db`)
- `--carousel-dot-active-color`: active dot color, also used on hover (default: `#1a1a2e`)
- `--carousel-dot-active-width`: active dot width (default: `var(--carousel-dot-size, 8px)`) — override to create a wider pill shape
- `--carousel-dot-active-radius`: active dot border-radius (default: `50%`) — override to flatten the circle into a pill (e.g. `4px`)

### CarouselCounter

**Purpose:** Text display of the current slide position, e.g. `1 / 5`. Updates automatically as slides change.

**Props:**
```tsx
{
  separator?: string;          // Character(s) between current and total (default: '/')
  className?: string;
  style?: React.CSSProperties;
}
```

## Usage Examples

### Minimal Carousel

```tsx
import Carousel, { CarouselTrack, CarouselSlide, CarouselPrev, CarouselNext } from '@hubspot/cms-component-library/Carousel';

<Carousel>
  <CarouselTrack>
    <CarouselSlide><div>Slide 1</div></CarouselSlide>
    <CarouselSlide><div>Slide 2</div></CarouselSlide>
    <CarouselSlide><div>Slide 3</div></CarouselSlide>
  </CarouselTrack>
  <CarouselPrev />
  <CarouselNext />
</Carousel>
```

### Carousel with Dots and Counter

```tsx
<Carousel>
  <CarouselTrack loop={true}>
    <CarouselSlide><div>Slide 1</div></CarouselSlide>
    <CarouselSlide><div>Slide 2</div></CarouselSlide>
    <CarouselSlide><div>Slide 3</div></CarouselSlide>
  </CarouselTrack>
  <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '16px', marginTop: '16px' }}>
    <CarouselPrev />
    <CarouselDots />
    <CarouselNext />
  </div>
  <CarouselCounter separator="of" />
</Carousel>
```

### Multi-slide Carousel with Gap

```tsx
<Carousel>
  <CarouselTrack perPage={3} gap={24} loop={false} align="start">
    <CarouselSlide><div>Item 1</div></CarouselSlide>
    <CarouselSlide><div>Item 2</div></CarouselSlide>
    <CarouselSlide><div>Item 3</div></CarouselSlide>
    <CarouselSlide><div>Item 4</div></CarouselSlide>
    <CarouselSlide><div>Item 5</div></CarouselSlide>
  </CarouselTrack>
  <CarouselPrev />
  <CarouselNext />
</Carousel>
```

### Autoplay Carousel

```tsx
<Carousel>
  <CarouselTrack autoplay={true} autoplayInterval={5000} loop={true}>
    <CarouselSlide><div>Slide A</div></CarouselSlide>
    <CarouselSlide><div>Slide B</div></CarouselSlide>
    <CarouselSlide><div>Slide C</div></CarouselSlide>
  </CarouselTrack>
  <CarouselDots />
</Carousel>
```

### Custom Arrow Icons

```tsx
<Carousel>
  <CarouselTrack>
    <CarouselSlide><div>Slide 1</div></CarouselSlide>
    <CarouselSlide><div>Slide 2</div></CarouselSlide>
  </CarouselTrack>
  <CarouselPrev>← Back</CarouselPrev>
  <CarouselNext>Next →</CarouselNext>
</Carousel>
```

### Module Usage Example

Carousel must live inside an island. The module's `Component` export uses `<Island>` to load it; all Carousel logic lives in the island file.

```tsx
// modules/TestimonialsModule/islands/TestimonialsIsland.tsx
import Carousel, {
  CarouselTrack,
  CarouselSlide,
  CarouselPrev,
  CarouselNext,
  CarouselDots,
} from '@hubspot/cms-component-library/Carousel';

type Testimonial = { quote: string; author: string };

type Props = {
  testimonials: Testimonial[];
  autoplay: boolean;
  autoplayInterval: number;
};

const TestimonialsIsland = ({ testimonials, autoplay, autoplayInterval }: Props) => (
  <Carousel>
    <CarouselTrack
      loop={true}
      autoplay={autoplay}
      autoplayInterval={autoplayInterval}
      gap={16}
    >
      {testimonials.map((item, i) => (
        <CarouselSlide key={i}>
          <blockquote>{item.quote}</blockquote>
          <cite>{item.author}</cite>
        </CarouselSlide>
      ))}
    </CarouselTrack>
    <div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '24px' }}>
      <CarouselPrev />
      <CarouselDots />
      <CarouselNext />
    </div>
  </Carousel>
);

export default TestimonialsIsland;
```

```tsx
// modules/TestimonialsModule/index.tsx
import { Island } from '@hubspot/cms-components';
// @ts-expect-error -- ?island not typed
import TestimonialsIsland from './islands/TestimonialsIsland.js?island';

type ComponentProps = {
  testimonials: { quote: string; author: string }[];
  autoplay: boolean;
  autoplayInterval: number;
};

export const Component = ({ testimonials = [], autoplay = false, autoplayInterval = 3000 }: ComponentProps) => (
  <Island
    module={TestimonialsIsland}
    testimonials={testimonials}
    autoplay={autoplay}
    autoplayInterval={autoplayInterval}
  />
);
```

## Accessibility

- **`CarouselPrev` / `CarouselNext`**: Both have `aria-label` ("Previous slide" / "Next slide") and are `disabled` when scrolling is not possible
- **`CarouselDots`**: The container has `aria-label="Slide navigation"`; each dot button has `aria-label="Go to slide N"` and `aria-current` on the active dot
- **`CarouselCounter`**: Has `aria-live="polite"` and `aria-atomic="true"` so screen readers announce slide changes
- **Reduced motion**: Transition duration is set to `0` when `prefers-reduced-motion: reduce` is active; autoplay does not start

## Slide Height — Making All Slides the Same Height

### Why slides can differ in height

`CarouselSlide` renders two nested divs:

```
<div>                  ← outer Embla wrapper (auto-stretches to tallest slide via flex align-items: stretch)
  <div className ...>  ← inner content div (receives your className / style)
    {children}
  </div>
</div>
```

The outer wrapper is automatically stretched to the height of the tallest slide because the track container is `display: flex` with `align-items: stretch` (the default). The inner content div is **not** — it only grows as tall as its content unless you explicitly set `height: 100%`. Without it, slides with less content will appear shorter than slides with more content.

**Default rule:** For text-based carousels, always apply `height: 100%` to the inner content div so all slides fill the stretched outer wrapper uniformly. Image-based carousels have layout trade-offs (see below) and the right strategy depends on the design intent.

---

### Text-based slides — equal height (default)

Apply `height: 100%` to the inner content div. Pair it with `min-height` to prevent the carousel from collapsing when content is very sparse, and `box-sizing: border-box` so padding doesn't cause overflow.

```css
/* carouselIsland.module.css */
.slide {
  height: 100%;             /* fills the auto-stretched Embla outer wrapper */
  min-height: 320px;        /* floor so the carousel is never too short */
  padding: 56px 64px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 16px;
  box-sizing: border-box;
  background: #1a1a2e;
  color: #fff;
}

.eyebrow {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.6);
}

.heading {
  margin: 0;
  font-size: 2rem;
  font-weight: 700;
  line-height: 1.2;
}

.body {
  margin: 0;
  font-size: 1rem;
  line-height: 1.65;
  color: rgba(255, 255, 255, 0.72);
}
```

```tsx
{slides.map((slide, i) => (
  <CarouselSlide key={i} className={styles.slide}>
    {slide.eyebrow && <span className={styles.eyebrow}>{slide.eyebrow}</span>}
    <h2 className={styles.heading}>{slide.heading}</h2>
    <p className={styles.body}>{slide.body}</p>
  </CarouselSlide>
))}
```

All slides grow to the height of the tallest slide. The shortest slides gain extra space at the bottom (since `justify-content: center` distributes it evenly above and below the content stack).

---

### Image-based slides — choose a strategy

Unlike text carousels, there is no universally correct layout for images. The right choice depends on the design. The two main strategies are **cover (clip)** and **contain (letterbox)**.

#### Strategy 1: Cover — image fills the box, excess is clipped

The image always fills the slide completely. Parts of the image that fall outside the fixed aspect ratio are cropped. This is the most common choice for hero and background-style image carousels.

**Effect:** Image is never distorted, never letterboxed. Edges may be cropped depending on the image's aspect ratio vs. the slide's fixed size.

```css
/* carouselIsland.module.css */
.slide {
  height: 480px;            /* fixed height — all slides are identical */
  position: relative;
  overflow: hidden;
}

.slideImage {
  width: 100%;
  height: 100%;
  object-fit: cover;        /* fills the box, crops the overflow */
  object-position: center;  /* crop anchor — adjust per design (e.g. 'top', '20% 80%') */
  display: block;
}

.slideCaption {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 24px 32px;
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.55));
  color: #fff;
}
```

```tsx
{slides.map((slide, i) => (
  <CarouselSlide key={i} className={styles.slide}>
    <img src={slide.image.url} alt={slide.image.alt} className={styles.slideImage} />
    {slide.caption && <p className={styles.slideCaption}>{slide.caption}</p>}
  </CarouselSlide>
))}
```

#### Strategy 2: Contain — full image visible, gaps are letterboxed

The entire image is always visible. If the image's aspect ratio doesn't match the slide's aspect ratio, the remaining space is filled with a background color (letterbox). Use this when showing the full image matters more than filling the slide edge-to-edge (e.g., product photography on a white background).

**Effect:** Image is never distorted, never cropped. Letterbox bars appear when aspect ratios differ.

```css
/* carouselIsland.module.css */
.slide {
  height: 480px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f5f5f5;      /* letterbox color — match your design background */
}

.slideImage {
  width: 100%;
  height: 100%;
  object-fit: contain;      /* full image visible, gaps filled with background */
  display: block;
}
```

```tsx
{slides.map((slide, i) => (
  <CarouselSlide key={i} className={styles.slide}>
    <img src={slide.image.url} alt={slide.image.alt} className={styles.slideImage} />
  </CarouselSlide>
))}
```

#### Summary

| Strategy | `object-fit` | Image distorted? | Image cropped? | Letterbox bars? | Best for |
|---|---|---|---|---|---|
| Cover | `cover` | No | Yes (edges) | No | Hero images, backgrounds, photography |
| Contain | `contain` | No | No | Yes (when AR differs) | Product shots, logos, images where full visibility matters |

For image carousels, do not apply `height: 100%` to the inner div — use a fixed `height` (or fixed aspect ratio via `aspect-ratio`) on the slide itself so all slides share an explicit shared size rather than inheriting from the tallest slide's content.

---

## Constraints

- All sub-components (`CarouselTrack`, `CarouselPrev`, etc.) must be rendered inside a `<Carousel>` provider; using them outside throws an error: `"Carousel sub-components must be used within <Carousel>"`
- There are no field definitions (`ContentFields` / `StyleFields`) for this component — the module author wires up fields directly in their module's fields file
- `perPage` and `gap` are applied as CSS variables on the track element, not as Embla options — this means responsive per-page changes should be handled via CSS overrides on `--carousel-slide-size`
