# Onboarding Carousel

> A React onboarding carousel of first-run slides for an app or PWA: swipe or tap Next, follow the dots, Skip any time, then Get started.

- Type: React component (Mobile)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add onboarding-carousel`
- Docs: https://wingo-ui.com/components/onboarding-carousel

## Interaction

Swipe the slides with a finger, or drag them with a mouse or trackpad (embla's physics, vertical page scrolling is never blocked); the illustration trails the swipe at 0.6x and the text at 0.9x, and each slide's background color cross-fades into the next as you go; a finger swipe that lands on a new slide ticks once (haptics). Tap Next to advance; on the last slide it becomes Get started with a quick fade (the circle variant grows from a round arrow, ringed by the progress, into a pill). Skip in the top corner jumps to the last slide (or finishes right away with skipTo="complete") and fades out on the last slide; with showBack a back chevron slides in before Next from the second slide. Tap a dot or a story bar to jump there. With focus anywhere inside, Left and Right move one slide and Home / End jump to the ends; Enter or Space on Next advances, and focus is handed to Next when Skip or Back disappear. Each settled slide is announced ("Slide 2 of 4: ..."), off-screen slides are inert. Finishing fades the whole thing out before onComplete runs; with persistKey the finish (or a skip that ends it: skipTo="complete", closing the modal) is remembered and it never shows again. As a modal it opens from its trigger as a centered card on desktop and slides up as a full screen on a phone, focus lands on Next, and Escape or a click outside counts as Skip. Under reduced motion slides jump, there is no parallax, and everything else fades.

## Installation

```bash
npx wingo-ui@latest add onboarding-carousel
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/onboarding-carousel` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/onboarding-carousel.tsx`

npm dependencies: `npm i @radix-ui/react-dialog @radix-ui/react-visually-hidden motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Carousel](https://wingo-ui.com/components/carousel) ([Markdown](https://wingo-ui.com/components/carousel.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
"use client"

import { useRouter } from "next/navigation"
import { OnboardingCarousel, type OnboardingSlide } from "@/components/ui/onboarding-carousel"

const slides: OnboardingSlide[] = [
  {
    id: "clients",
    title: "Every client in one place",
    description: "Companies, contacts and balances, the same on your phone and at the office.",
    image: "/onboarding/clients.svg",
    background: "var(--info-soft)",
  },
  {
    id: "invoices",
    title: "Invoices in 30 seconds",
    description: "Issue the invoice from your phone and we email it to your client.",
    image: "/onboarding/invoices.svg",
    background: "var(--success-soft)",
  },
  {
    id: "offline",
    title: "Works offline too",
    description: "Everything syncs when you are back online.",
    image: "/onboarding/offline.svg",
  },
]

export default function Welcome() {
  const router = useRouter()
  return (
    <OnboardingCarousel
      layout="screen"
      slides={slides}
      persistKey="onboarding-v1"
      showBack
      labels={{ skip: "Skip", next: "Continue", back: "Back", done: "Get started", region: "Welcome" }}
      onComplete={() => router.replace("/home")}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `slides` | `OnboardingSlide[]` |  | { id, title, description?, media?, image?, imageAlt?, background?, foreground? }. media is any node (a composed ui vignette, an svg); image is contained in the media area, or full-bleed behind the text with a bottom scrim when foreground is "light". background is any CSS color or token (var(--info-soft)) and cross-fades while swiping; without one the root's surface shows. |
| `layout?` | `"fill" \| "screen" \| "card"` | `"fill"` | fill takes the parent's height (a route of its own inside an app shell). screen is min-h-dvh with safe-area padding at the top and bottom. card is a 420 x 640px rounded surface, capped at 85dvh. |
| `modal?` | `boolean` | `false` | Renders it in a Radix dialog with a focus trap and a hidden title: a full screen below 768px that slides up, a centered card from 768px. Closing (Escape, the overlay) counts as Skip. layout is ignored. |
| `defaultOpen?` | `boolean` | `false` | Modal mode: open on mount. The playground turns modal on with it. |
| `open?` | `boolean` |  | Modal mode, controlled. Pair with onOpenChange. |
| `onOpenChange?` | `(open: boolean) => void` |  | Modal mode: the trigger opened it, or Skip, Escape, the overlay or the last button closed it. |
| `trigger?` | `ReactNode` |  | Modal mode: the element that opens it, rendered asChild (a Button). |
| `indicator?` | `"dots" \| "bars" \| "progress" \| "counter"` | `"dots"` | The carousel's indicators: dots whose active one stretches, story bars, one progress bar, or a 2 / 4 counter. Hidden with a single slide. |
| `indicatorPosition?` | `"top" \| "bottom"` | `"bottom"` | bottom sits above Next (beside the circle). top puts it in the header next to Skip, the stories look. |
| `nextVariant?` | `"button" \| "circle"` | `"button"` | button: a full-width Next that becomes Get started. circle: a round arrow ringed by the progress that grows into the Get started pill on the last slide. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Title 24 / 28 / 32px, description 15 / 16 / 17px, indicators, and the Next button (40 / 48 / 48px, 44px on touch at sm; the circle 48 / 56 / 64px). |
| `align?` | `"center" \| "start"` | `"center"` | Text alignment of the title and description. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The Next / Get started button, the circle's ring and the active dot or bar. |
| `color?` | `string` | `""` | Any CSS color. Replaces the tone; text on the button turns black or white by lightness. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape of the Next and Back buttons in the button variant (the circle is always round). |
| `showSkip?` | `boolean` | `true` | The Skip button in the header. It fades out on the last slide. |
| `showBack?` | `boolean` | `false` | A back chevron before Next from the second slide. |
| `skipTo?` | `"last" \| "complete"` | `"last"` | Skip jumps to the last slide, or finishes right away (fade out, then onSkip and onComplete). |
| `parallax?` | `boolean` | `true` | While swiping, the media moves at 0.6x the slide and the text at 0.9x. Off under reduced motion. |
| `haptics?` | `boolean` | `true` | A "selection" tick through lib/haptics when a finger or pen swipe lands on a new slide. Never on a mouse drag, taps, keys or mount; silent on desktops. |
| `persistKey?` | `string` | `""` | When set, a finish or a skip that ends the tour (skipTo="complete", closing the modal) is remembered in localStorage (use-persisted-state) and nothing renders afterwards; a modal stays closed unless its trigger opens it. The server renders the slides; a remembered finish fades them out after hydration (a modal never flashes). Clear it with writePersisted(key, null). |
| `index?` | `number` |  | The current slide, controlled. A jump scrolls there (instantly under reduced motion); values past the end clamp. |
| `defaultIndex?` | `number` | `0` | The first slide shown, uncontrolled. The tour also returns here after it finishes. |
| `onIndexChange?` | `(index: number) => void` |  | Every settled change: a swipe, Next, Back, Skip, a dot or a key. |
| `onComplete?` | `() => void` |  | The last slide's button (and Skip with skipTo="complete"), after the fade out. |
| `onSkip?` | `(index: number) => void` |  | Skip was pressed (or the modal was closed) on this slide. |
| `labels?` | `Partial<OnboardingCarouselLabels>` |  | skip "Skip", next "Next", back "Back", done "Get started", region "Welcome" (the region label and the modal title fallback), slide(i, n) "Slide 2 of 4", goTo(i) "Go to slide 3". Pass only the keys you translate. |
| `setApi?` | `(api: CarouselApi) => void` |  | The embla api, for custom controls. |
| `classNames?` | `Partial<Record<"header" \| "skip" \| "slide" \| "media" \| "title" \| "description" \| "footer" \| "indicator" \| "back" \| "next", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root, merged last. Override the tone vars here, e.g. [--tone:#0f766e]. |

## Parts

### OnboardingSlide (type)

One slide: id, title, description, media (any node) or image with imageAlt, background (any CSS color, cross-fades while swiping) and foreground ("light" for photos and dark backgrounds).

### DEFAULT_ONBOARDING_CAROUSEL_LABELS (constant)

The English copy: skip "Skip", next "Next", back "Back", done "Get started", region "Welcome" and the slide announcements.

## Examples

- **Circle next, brand tone**: A round arrow ringed by the progress that grows into the Get started pill on the last slide.
- **As a modal**: A trigger opens it as a centered card on desktop and a full screen on a phone.
- **Photo slides**: Full-bleed photos with foreground="light": white text and controls over a scrim.
- **Stories bars on top**: indicatorPosition="top" puts the bars in the header next to Skip.
- **Remembered once seen**: persistKey stores a finish or a skip; Reset clears it and the tour comes back.
- **Start aligned, large, default labels**: align="start", size="lg", the progress bar and the built-in default labels.

## See also

- [Carousel](https://wingo-ui.com/components/carousel) ([Markdown](https://wingo-ui.com/components/carousel.md))
- [Tour](https://wingo-ui.com/components/tour) ([Markdown](https://wingo-ui.com/components/tour.md))
- [Install Prompt](https://wingo-ui.com/components/install-prompt) ([Markdown](https://wingo-ui.com/components/install-prompt.md))
- [Steps](https://wingo-ui.com/components/steps) ([Markdown](https://wingo-ui.com/components/steps.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))

---

Canonical: https://wingo-ui.com/components/onboarding-carousel
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
