# Carousel

> A scroll-snap carousel with native momentum, mouse drag and arrow keys. Its dot stretches between pages as you scroll and its edges fade only where there is more.

- Tier: Free (MIT)
- Interaction: Scroll
- Page: https://eased.dev/components/carousel
- Installs: motion
- Registry: https://eased.dev/r/carousel.json

## Install

```bash
npx shadcn add https://eased.dev/r/carousel.json
```

A horizontal carousel on native scroll snapping: the platform's momentum on touch and trackpads, mouse drag with flick projection, previous/next buttons, arrow keys and a page indicator that follows the scroll continuously.

## When to use

- A row of cards wider than the space: customer stories, listings, plans, featured products.
- Content people browse in order but don't need to compare side by side.

## When not to use

- Content everyone must see: lay it out in a grid instead.
- Switching between views of one thing: use [Tabs](/components/tabs) or [Segmented](/components/segmented).
- Auto-advancing banners: this carousel never autoplays.

## Usage

```tsx stories.tsx
import { Carousel } from "@/components/eased/carousel/carousel";

export function Stories() {
  return (
    <div className="[--carousel-inset:16px]">
      <Carousel aria-label="Customer stories" slideSize="min(85%, 22rem)">
        {stories.map((s) => (
          <StoryCard key={s.id} story={s} />
        ))}
      </Carousel>
    </div>
  );
}
```

## Examples

### Progress bar indicator

```tsx
<Carousel aria-label="Listings" indicator="bar" slideSize="16rem" />
```

## API reference

### Carousel

| Prop | Type | Default | Description |
|---|---|---|---|
| `aria-label` | `string` | | Required. Names the carousel. |
| `slideSize` | `string` | `"80%"` | Any CSS length. |
| `gap` | `number` | `12` | px between slides. |
| `align` | `"start" \| "center"` | `"start"` | Where a slide rests. |
| `indicator` | `"dots" \| "bar" \| "none"` | `"dots"` | |
| `controls` | `boolean` | `true` | Previous and next buttons. |
| `loop` | `boolean` | `false` | Next on the last page returns to the first. |
| `draggable` | `boolean` | `true` | Mouse drag. Touch and trackpads always scroll natively. |
| `index` / `defaultIndex` | `number` | `0` | The page, controlled or uncontrolled. |
| `onIndexChange` | `(index) => void` | | Fires as the nearest page changes, during scroll too. |
| `footer` | `ReactNode` | | Replaces the indicator on the left of the footer. |
| `className` / `slideClassName` | `string` | | |

Set `--carousel-inset` on a parent to inset the first slide and the footer from the edges while slides still scroll to them.

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Focuses the track, then the dots and buttons. |
| `←` / `→` | Previous or next page. |
| `Home` / `End` | First or last page. |

## Accessibility

- A region with `aria-roledescription="carousel"`; each slide is a group named "3 of 5".
- The current page is announced from a polite live region; dots carry `aria-current`.
- Pages are snap stops, so slides that share the last stop count as one page.

## Motion

Nothing animates on a timer: scrolling is native, so momentum and rubber-banding are the platform's own. The dot highlight is a clip-path on one bar that follows the fractional scroll position: between pages its leading edge runs ahead and its trailing edge catches up, so it stretches into a short worm and settles back to a dot. A mouse flick is projected with UIScrollView friction onto the stop it would coast to. Edges feather 20px only where there is more to scroll. Reduced motion jumps instead of smooth-scrolling.

## Notes for AI

Use Carousel for a browsable row of cards. Give each slide a card with its own radius, set `slideSize` so the next one peeks in, and set `--carousel-inset` to the container's padding.

Don't add autoplay, a second scroll container or your own snap CSS; don't wrap slides in links that fire on drag (clicks after a drag are swallowed for you).

## Related

- [Scroll Area](/components/scroll-area): free scrolling without pages.
- [Tabs](/components/tabs): switching between panels.

## Example

`components/eased/carousel/carousel-demo.tsx`

```tsx
"use client";

import { Avatar } from "../avatar/avatar";
import { DemoFrame } from "../lib/demo-frame";
import { Carousel } from "./carousel";

const STORIES = [
  {
    metric: "9 → 2 days",
    label: "to close the books each month",
    quote: "Reconciliation used to eat the first week of every month. Now the ledger is clean by the 2nd and finance spends that week on forecasting.",
    name: "Maya Okafor",
    role: "Controller, Fieldwork",
  },
  {
    metric: "€184k",
    label: "in duplicate payments caught in Q2",
    quote: "It flagged two invoices from the same supplier with different PO numbers on day one. That alone paid for the year.",
    name: "Jonas Weber",
    role: "Head of AP, Nordlicht",
  },
  {
    metric: "3.4×",
    label: "more invoices per person",
    quote: "We grew from 40 to 130 suppliers without hiring. Approvals happen in Slack and nobody chases signatures any more.",
    name: "Priya Raman",
    role: "Finance Ops Lead, Tidepool",
  },
  {
    metric: "41 min",
    label: "from receipt to approved expense",
    quote: "People snap a receipt and forget about it. Policy checks run before a manager ever sees it.",
    name: "Tomás Herrera",
    role: "CFO, Brightline Studio",
  },
  {
    metric: "0",
    label: "audit findings for the first time in four years",
    quote: "Every approval has a trail our auditors could follow without asking us a single question.",
    name: "Hana Sato",
    role: "VP Finance, Kumo Health",
  },
];

/** Customer stories: swipe, drag with a mouse, press the arrows or focus the track and use arrow keys. */
export function CarouselDemo() {
  return (
    <DemoFrame title="Customer stories" meta={`${STORIES.length} teams`}>
      <div className="flex min-h-0 flex-1 flex-col py-4 [--carousel-inset:16px]">
        <Carousel aria-label="Customer stories" slideSize="min(85%, 22rem)" className="min-h-0 flex-1" slideClassName="flex">
          {STORIES.map((s) => (
            <figure
              key={s.name}
              className="flex w-full flex-col rounded-control border border-line-subtle bg-surface-sunken p-4 select-none"
            >
              <p className="text-display-2xl font-semibold text-text tabular-nums">{s.metric}</p>
              <p className="mt-0.5 text-ui-sm text-text-muted">{s.label}</p>
              <blockquote className="mt-4 line-clamp-5 text-ui text-pretty text-text-secondary">“{s.quote}”</blockquote>
              <figcaption className="mt-auto flex items-center gap-2.5 pt-4">
                <Avatar name={s.name} size="md" />
                <span className="flex min-w-0 flex-col">
                  <span className="truncate text-ui-sm font-medium text-text">{s.name}</span>
                  <span className="truncate text-ui-xs text-text-muted">{s.role}</span>
                </span>
              </figcaption>
            </figure>
          ))}
        </Carousel>
      </div>
    </DemoFrame>
  );
}
```
