# Carousel

> A looping carousel with neighbours peeking in and a progress dot per card: autoplay pauses on interaction and off screen, and waits for Play with reduced motion.

Surfaces · [Docs](https://ui.ssntl.app/docs/components/carousel) · `npx shadcn@latest add @ssntl/carousel`

Installs `components/ui/carousel.tsx`, with `utils`, `button`, `details`, `scrollbar`. Read this file before you use, change or review it.

## When to use it

- For a few large, equal stories people browse in turn: features, projects, places.

## When not to

- **For content everyone must see.** Only the current card is in view.
- **More than about eight cards.** Use a grid or a Rail.

## Usage

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselControls,
  CarouselItem,
  CarouselPlayButton,
  CarouselTab,
  CarouselTabList,
} from "@/components/ui/carousel"

;<Carousel duration={4000}>
  <CarouselContent>
    {items.map((item) => (
      <CarouselItem key={item.id}>
        <img src={item.src} alt={item.alt} />
      </CarouselItem>
    ))}
  </CarouselContent>
  <CarouselControls>
    <CarouselTabList aria-label="Our work">
      {items.map((item) => (
        <CarouselTab key={item.id}>{item.title}</CarouselTab>
      ))}
    </CarouselTabList>
    <CarouselPlayButton playLabel="Play our work" pauseLabel="Pause our work" />
  </CarouselControls>
</Carousel>
```

## Rules for Carousel

- **Pausing.** Autoplay has a play button (WCAG 2.2.2) and pauses by itself when people scroll, swipe, focus a dot or the carousel leaves the screen.
- **Reduced motion.** No autoplay until Play is pressed, and cards jump instead of sliding.
- One tab per card, in the same order, whose text names the card; arrows, Home and End move between them (WAI-ARIA carousel with tabs).
- Only the current card can be reached: clones and peeking cards are inert, so nobody follows a link on a card they aren't looking at.
- The progress fill is the clock: a scale animation on the compositor that pauses in place; the dot grows into a pill over `--duration-panel`.
- Dots are 8px; the target around each is at least 32 × 48px, well over WCAG's 24px for a dense set.
- Photos fill the card with a 1px outline at 10%; a caption sits on a dark fade, and peeking cards dim theirs.
- **Content also renders in clones.** Check `isClone` (`useCarouselItem`) before playing media or tracking impressions.

## Keyboard

| Key           | Action                                                                     |
| ------------- | -------------------------------------------------------------------------- |
| Tab           | Moves to the current card, then the dots (one stop), then the play button. |
| ← / →         | On the dots: the previous or next card, wrapping around.                   |
| Home / End    | On the dots: the first or last card.                                       |
| Enter / Space | On the play button: plays or pauses.                                       |

## How it is built

A looping carousel with neighbours peeking in and a progress dot that fills while each card is up. Adapted from Chánh Đại's Apple Carousel (MIT, chanhdai.com), rebuilt on the system's tokens without a motion library.

- WAI-ARIA carousel with tabs: each dot is a tab for its card, arrows, Home and End move between them, and the pause button stops autoplay (WCAG 2.2.2). Autoplay also pauses when people scroll, swipe, focus a dot or the carousel leaves the screen.
- The loop: a few cards are cloned at each end; when a clone settles in place, the scroll jumps to its real twin, which looks the same. Clones and the peeking cards are inert, so nobody follows a link on a card they aren't looking at.
- The progress fill is the autoplay clock: one scale animation on the compositor that pauses in place, with no work per frame.
- Reduced motion: no autoplay until Play is pressed, and cards jump instead of sliding.

How long after a scroll before it counts as settled (ms).

Where the viewport scrolls to center a card (or align it to the start on narrow screens, where the cards snap to the start).

"auto" waits under reduced motion; pressing Play is an explicit choice, so "playing" ignores the preference (and cards jump instead of sliding).

Our own scrolls (smooth or instant) must not count as people scrolling.

A clone looks like its twin, so jumping to the real card is invisible.

scrollend isn't everywhere yet; the timer backs it up.

A vertical wheel scrolls the page, not the carousel.

The progress fill is the clock: it scales across the current dot and, when it finishes, the next card comes up.

Clones wait for hydration, so the server markup already shows the first card in place with no script.

A 1px outline at 10% keeps a light photo's edge on any surface.

Names the set: “Our clients' businesses”.

The icon names the action: a playing carousel shows pause.

## API notes

- `index`: The item's position among the items in CarouselContent.
- `isCurrent`: Whether this item is the one being shown.
- `isClone`: Whether this copy is a clone that keeps the loop seamless.
- `useCarouselItem`: The enclosing item's state. Content also renders in clones: check isClone before playing media or tracking impressions. */
- `duration`: How long each card stays up during autoplay, in ms.
- `CarouselControls`: The dots and the play button, centered under the cards.
- `CarouselTab`: One dot per item, in the same order; its text names the item for screen readers. The dot is 8px, the target around it at least 32 × 48px. */

## Motion

- Animate only to explain a change; the interface is complete without motion.
- **Durations from tokens.** `--duration-fast` 150 (press, hover), `--duration-pop` 180 (tooltips, icon swaps), `--duration-menu` 220 (menus, small moves), `--duration-panel` 320 (modals, sheets).
- **Easing from tokens.** `--ease-out` for entrances, exits and presses; `--ease-in-out` for things already on screen moving to a new place; linear for progress only.
- Use transitions for interactions so they can be interrupted; keyframes only for one-off sequences.
- Name the properties you transition (transition-[scale,opacity]); never transition: all.
- **Don't animate on page load.** Content that is already there appears at once; entrance motion is for things that arrive later.
- No custom animation on high-frequency interactions (typing, toggling in a list, hovering a table, keyboard shortcuts, a command palette); the cost repeats every time.
- Never `ease-in` on interface motion: it starts slow, exactly when the person is watching.
- **Never animate from scale(0).** Start at 0.9–0.97 with opacity 0 (`pop-in` starts at 0.96). The one exception is a cross-fading icon (`icon-swap`), which starts at 0.25 behind a blur.
- Popovers and menus grow from their trigger (transform-origin at the trigger); modals stay centered.
- Keep animated blur under 20px; a 2–4px blur masks an imperfect cross-fade.
- `will-change` only once you see first-frame stutter, naming the property that animates (scale, not transform), and never on an element that doesn't animate; it also makes the element the container of fixed descendants.
- Entrances stagger 100ms apart (80ms per word in a headline): each chunk rises 12px out of a 4px blur over `--duration-panel`, and exits travel less, faster.
- Icon cross-fades run on `--duration-pop` with cubic-bezier(0.2, 0, 0, 1), and only for infrequent swaps; a tab's icon or an action revealed on hover swaps instantly. (better-ui suggests 300ms; the system keeps its 180ms token, under the 300ms limit for interface motion.)
- Exits are quicker and travel less than entrances; animate opacity, translate, scale and filter, never layout properties.
- Under reduced motion keep opacity and color only. No state lives in motion alone.

## Interaction

- Every control has an accessible name: its visible label, or `aria-label` for icon-only controls.
- Every keyboard-reachable control shows a visible focus ring (2px, offset 2px, the ring token).
- Anything you can do with a pointer you can do with the keyboard.
- Targets are 48px (44px minimum for dense rows), with at least 8px between neighbours (the system's density); the hit area covers the whole visible control.
- Press feedback is 0.96 scale in 150ms (`--es-press`, `--duration-fast`); never below 0.96, never slow, and a disabled control never scales.
- One primary action per view; past three secondary actions, the rest go in a menu.
- Hover styles only on pointer devices; never hide a control's function behind hover.
- **Undo beats confirmation.** A frequent, reversible action runs at once and offers Undo (Order archived. Undo). Confirm only what can't be undone, affects other people or destroys many items. A destructive action is red text, placed last.
- Never disable a submit button until the form is valid; validate on submit. While pending, use `aria-disabled="true"` with a spinner beside the label, since disabled drops focus.

## Images

- Decorative images take `alt=""`; an image that carries information gets alt text that says what it shows.
- Images get a 1px pure black (white in dark) outline at 10%, offset −1px; transparent artwork such as logos and cut-out objects skips it.
- Images in a set share one ratio and are cropped to fit; serve twice the displayed size and set width and height.
- Never put text on an image without a scrim; never use a brand-bearing asset you don't have rights to.

## Design tokens only

- Use the system's tokens for every design value: type roles (`text-caption` 13, `text-label` 15, `text-body` 17, `text-lead` 20, `text-heading` 22, `text-title` 32, `text-headline` 48, `text-display` 72), radii (`rounded-xs` 8 … xl 44), spacing steps, colors, shadows and motion. If a value doesn't exist, take the nearest token. Never write text-[15px], rounded-[24px] or bg-[#…]; `pnpm lint` rejects them.
- Type roles already carry line height, tracking and weight. Pick the role; never add `leading-*` or `tracking-*` on top.
- **Nested corners are concentric.** Inner radius = outer radius − padding − border width (`rounded-outer`-* p-concentric-* `rounded-inner`). Past 24px of padding, or with deliberately uneven padding, each layer keeps its own radius token.
- Separate surfaces by tone, not borders. A border is for structure (a divider) or state (an input, a selection).

## Accessibility baseline

- Text meets 4.5:1 against its surface (3:1 at 24px and up, or 18.67px bold); input borders, focus rings and chart marks meet 3:1, in light and dark. Text on a translucent color (`white/80`, /50, color-mix) is measured as rendered over its background; the theme pairs are checked in lint.
- Meaning never rides on color alone: a state carries a word, an icon or a pattern as well.
- Every page fits a 320px screen at 200% zoom with no horizontal scroll; a grid that holds content uses grid-cols-[minmax(0,1fr)] and flex children get min-w-0. Never set maximum-scale=1 or user-scalable=no.
- A focused element is never hidden under sticky chrome: set scroll-padding to the header's height (and to footers or banners at the bottom).
- On a client-side route change, update the title and move focus to the new page's main content.
- **Respect prefers-reduced-motion.** Keep opacity and color, drop movement, scale and blur.
- Content is never truncated without a way to read all of it; long names wrap. A hover-only title tooltip doesn't count as a way back.

## Writing

- Sentence case everywhere; no ALL CAPS except the Eyebrow role.
- One concept, one word, across navigation, headings and buttons.
- Buttons are a verb and its object and say what happens (Delete product, not OK).
- An error says what went wrong and how to fix it, in the user's language.
- **Status copy follows one pattern.** In progress is the verb in -ing (Saving changes…), done names the object and the past tense with no “successfully” (Changes saved), a failure gives the next step, partial results give counts. Cut “Please”.
- A confirmation names the action and the object (Delete Ceramic cup?), never “Are you sure?”. Deleting an account, a workspace or a store asks for its name to be typed.
- Show known requirements as helper text before input, not only as an error after it. Links never say “Click here”, and no two links on a page say “Learn more”.
- Build each sentence as one translatable message, never concatenated around a variable; join lists with Intl.`ListFormat`.
- **Use real characters.** An en dash for ranges (09:30–17:45), × for dimensions, curly quotes, an ellipsis (…).

## Fixing

- Take the cheapest fix that works, in this order: delete (a separator space would carry, an animation on a frequent action, an ARIA attribute a native element makes redundant), use the platform (the native element or control, the browser's focus ring), reuse what the system has (a token, a spacing step, a motion curve), correct the value, and only then add something new.
- Code that adds what a deletion would fix is a problem in itself; fix it by removing.
- A documented convention settles matters of taste, but never excuses a release blocker.

## Worst-case content

- Build and test with the worst real values, not the happy ones: long hyphenated names with diacritics, unbreakable emails and URLs, German compounds, translations about 30% longer, emoji and right-to-left names.
- Unbreakable strings wrap anywhere; nothing pushes its siblings off a row or out of a container.
- Every image has a fallback and a box: a failed avatar shows initials, a frame keeps its ratio, so loading never shifts the layout.
- **Show data as data.** Escape HTML, never render null, undefined or NaN, and round floating point before display.
- **Check the environment as well.** A 280px column, a 2560px screen, 200% zoom, dark mode, `dir="rtl"` and touch.

## Icons

- One icon set (Lucide) at one stroke: 1.6, the Essential Design spec, beside regular and medium text; a heavier stroke only where a small glyph must read (a 16px check). (better-ui proposes rendered strokes of 1.5–2px with `absoluteStrokeWidth`; the system's documented stroke takes precedence as a matter of style.)
- Inline icons are 1em–1.25em of the text beside them.
- Icons are 20px beside labels and 16px in dense controls, at the size they were drawn; a well or a button, not a bigger glyph, gives them room.
- One SVG per icon, colored with `currentColor`; hover, selected and disabled states come from color and opacity, never from separate assets.
- Decorative icons are `aria-hidden`; an icon that is the only content of a control gets its name from the control.

## Light, dark and languages

- Design both themes from the tokens; dark mode is chosen, not inverted. Check every new pair in both.
- Pass every visible and accessible string as a prop or child, so the component works in any language; never hard-code copy inside it.
- Use logical properties (ms-, me-, start-, end-) so layouts mirror in right-to-left languages; `translateX` and background-position need a right-to-left override. Mirror only icons whose meaning follows reading direction (arrows, chevrons), and wrap mixed-direction values in `<bdi>`.
- **Visual order matches DOM order.** No order, row-reverse or grid placement that reorders content.
- Switching themes pauses transitions for the switch, so every surface changes at once.

## Before you ship

- [ ] Every interactive control has an accessible name.
- [ ] Every keyboard-reachable control shows a visible focus indicator.
- [ ] Nothing is reachable by pointer but not by keyboard.
- [ ] Motion and auto-playing content respect prefers-reduced-motion.
- [ ] Nothing is clipped, overlapped or unreachable at 320px width or 200% zoom.
- [ ] Body and control text pass their contrast ratio.
- [ ] No state or meaning is carried by color alone.
- [ ] Destructive actions have a confirmation, an undo, or a distinct treatment.
- [ ] No content is truncated without a way to reach the full value.
- [ ] No content or control hides past a scroll edge or disclosure without a visible cue.
- [ ] Every error names a way to recover.
- [ ] Semantic colors are used for their meaning only (danger never on a safe action).
- [ ] No state change is carried by motion alone.
- [ ] Nothing focused hides under sticky chrome, and overlays contain their own scroll.
- [ ] Worst-case values (long, empty, many, missing, right-to-left) don't break the layout.

## Review with these skills

Every interface built with ssntl/ui is reviewed with the same skills the library is. Install them once and keep them current:

```bash
npx skills@latest add https://github.com/jakubkrehel/skills --skill better-interface
npx skills@latest add jakubkrehel/make-interfaces-feel-better
npx skills@latest add emilkowalski/skills
```

- **better-interface** (Jakub Krehel): The full review: accessibility, layout, writing, typography, color and polish. Its escalation triggers block a release.
- **make-interfaces-feel-better** (Jakub Krehel): The details: concentric radii, optical alignment, press scale, interruptible and subtle motion, hit areas.
- **emilkowalski/skills** (Emil Kowalski): Design engineering: easing, durations, springs and gestures (emil-design-eng, review-animations, animation-performance) and the worst-case values that break a UI (break-ui).

## Related

- [Marquee](marquee.md): A strip that drifts on its own for showcases: pauses on hover, focus, off screen and with a 48px button, and stands still with reduced motion.
- [Rail](rail.md): A scrolling row of cards with room to swipe and full-height edge navigation.
- [Frame](frame.md): A fixed-ratio image frame so every image in a set lines up, cropped or contained.

---

Generated from the ssntl/ui source by `scripts/build-rules.mjs`. The whole rulebook is [ssntl-ui.md](ssntl-ui.md); every component's rules are at https://ui.ssntl.app/rules.
