# Ticker Component

A continuously scrolling marquee/ticker component for ambient, looping content. Renders children twice (one visible set, one aria-hidden duplicate) and uses a CSS `@keyframes` animation to create a seamless infinite scroll. Animation speed and timing are calculated dynamically from the content width and the `speed` prop.

## Import path

```tsx
import Ticker from '@hubspot/cms-component-library/Ticker';
```

## When to use Ticker vs Carousel

Use **Ticker** when:
- Content should scroll continuously with no user navigation (logo strips, partner logos, announcement banners, social proof marquees, brand strips)
- The animation is ambient or decorative — users do not need to stop on a specific item
- There is no meaningful "slide 1 of N" structure; items flow past like a news ticker

Use **Carousel** instead when:
- Content is finite and users need to navigate to a specific slide
- Users need to pause, read, or interact with individual items (testimonials, product cards, hero images)
- Prev/next buttons, dots, or a counter are needed

## Island Requirement

**Ticker must be rendered inside an island.** It uses `useState`, `useEffect`, and `ResizeObserver` — none of which can run during server-side rendering. Placing `<Ticker>` 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 `Ticker` usage inside an island file (e.g. `islands/TickerIsland.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/TickerIsland.tsx
import Ticker from '@hubspot/cms-component-library/Ticker';

type Props = { items: string[]; speed: number; pauseOnHover: boolean };

const TickerIsland = ({ items, speed, pauseOnHover }: Props) => (
  <Ticker speed={speed} pauseOnHover={pauseOnHover} gap={40}>
    {items.map((item, i) => <span key={i}>{item}</span>)}
  </Ticker>
);

export default TickerIsland;
```

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

export const Component = ({ items = [], speed = 50, pauseOnHover = true }) => (
  <Island module={TickerIsland} items={items} speed={speed} pauseOnHover={pauseOnHover} />
);
```

## Component Structure

```
Ticker/
├── index.tsx              # Main component implementation
├── types.ts               # TypeScript type definitions
└── index.module.scss      # CSS module — keyframe animation and layout
```

## Component

### Ticker

**Purpose:** Wraps `children` in a horizontally scrolling container. Children are rendered twice — the first set is visible; the second is aria-hidden to prevent duplicate screen reader announcements. A `ResizeObserver` recalculates the animation duration whenever the content size changes so the scroll speed stays constant regardless of viewport size.

**Props:**
```tsx
{
  speed?: number;              // Scroll speed in pixels per second (default: 50)
  pauseOnHover?: boolean;      // Pause animation when the user hovers (default: true)
  gap?: number | string;       // Gap between items — number = px, string = any CSS unit
  className?: string;          // Applied to the outer wrapper div
  style?: React.CSSProperties; // Applied to the outer wrapper div
  children: React.ReactNode;   // Items to scroll — rendered twice internally
}
```

**How speed works:** The component measures the pixel distance between the two content sets and divides it by `speed` to compute the CSS animation duration in seconds. A higher `speed` value = faster scroll. If `speed` is `0` or the content has zero width, the animation stops.

**Reduced motion:** When `prefers-reduced-motion: reduce` is active, the animation duration is set to `0s` (effectively stopped). The `ResizeObserver` also listens for media query changes and updates the duration live.

## CSS Variables

These CSS variables are set dynamically by the component at runtime and are not intended to be overridden from outside:

- `--ticker-duration` — animation duration in seconds, derived from content width ÷ speed
- `--ticker-shift` — the translateX target in pixels, calculated from the offset between the two content sets

## CSS Classes

- `.ticker` — `overflow: hidden`, full width; the visible clipping container
- `.list` — `display: flex`, `width: max-content`; the animating element
- `.list.running` — applies `animation: tickerScroll var(--ticker-duration) linear infinite`
- `.set` — `display: flex`; one copy of the children; applied to both the visible and the aria-hidden set
- `.ticker.pauseOnHover:hover .list` — sets `animation-play-state: paused` when `pauseOnHover={true}`

## Usage Examples

### Basic Logo Ticker

```tsx
import Ticker from '@hubspot/cms-component-library/Ticker';

<Ticker>
  <img src="/logo-a.svg" alt="Company A" />
  <img src="/logo-b.svg" alt="Company B" />
  <img src="/logo-c.svg" alt="Company C" />
  <img src="/logo-d.svg" alt="Company D" />
</Ticker>
```

### Faster Ticker with Gap

```tsx
<Ticker speed={80} gap={40}>
  <img src="/logo-a.svg" alt="Company A" />
  <img src="/logo-b.svg" alt="Company B" />
  <img src="/logo-c.svg" alt="Company C" />
</Ticker>
```

### Ticker that Does Not Pause on Hover

```tsx
<Ticker pauseOnHover={false} speed={30}>
  <span>Breaking news item one</span>
  <span>Breaking news item two</span>
  <span>Breaking news item three</span>
</Ticker>
```

### Ticker with CSS Gap String

```tsx
<Ticker gap="3rem">
  <div className="logo-item"><img src="/a.svg" alt="A" /></div>
  <div className="logo-item"><img src="/b.svg" alt="B" /></div>
  <div className="logo-item"><img src="/c.svg" alt="C" /></div>
</Ticker>
```

### Module Usage Example

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

```tsx
// modules/LogoStripModule/islands/LogoStripIsland.tsx
import Ticker from '@hubspot/cms-component-library/Ticker';

type Logo = { src: string; alt: string };

type Props = { logos: Logo[]; speed: number; gap: number };

const LogoStripIsland = ({ logos, speed, gap }: Props) => (
  <Ticker speed={speed} gap={gap} pauseOnHover={true}>
    {logos.map((logo, i) => (
      <img key={i} src={logo.src} alt={logo.alt} style={{ height: '40px', width: 'auto' }} />
    ))}
  </Ticker>
);

export default LogoStripIsland;
```

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

type Logo = { src: string; alt: string };

type ComponentProps = { logos?: Logo[]; speed?: number; gap?: number };

export const Component = ({ logos = [], speed = 50, gap = 40 }: ComponentProps) => (
  <Island module={LogoStripIsland} logos={logos} speed={speed} gap={gap} />
);
```

## Accessibility

- The second copy of children is rendered with `aria-hidden="true"` so screen readers do not announce the content twice
- When `prefers-reduced-motion: reduce` is set, the animation is stopped (`duration: 0`) — content is still visible but static
- The component does not trap focus or intercept keyboard events

## Constraints

- The Ticker component is a **single component** — there are no sub-components or compound patterns
- There are no field definitions (`ContentFields` / `StyleFields`) — the module author wires up fields directly
- `gap` is applied via the CSS `gap` property on both `.set` divs and the `.list` div. It does not affect spacing between the two duplicate sets — that gap is determined by the natural width of the content
- The animation only starts once `duration > 0` (i.e., after the `ResizeObserver` fires in the browser). During SSR, no animation plays
- Items passed as `children` must have fixed or deterministic dimensions for the speed calculation to be accurate. Lazy-loaded images without explicit width/height may cause a brief speed miscalculation until they load
