# Swipeable Tabs

> React swipeable tabs whose pages sit side by side and follow your thumb: swipe and the indicator tracks the finger, tap and the pages slide.

- 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 swipeable-tabs`
- Docs: https://wingo-ui.com/components/swipeable-tabs

## Interaction

Swipe the content sideways with a finger: the pages move with native momentum, one page per swipe, and the underline, pill or segmented thumb stretches between tabs in step with the pages; the label color flips half way and a light selection tick plays when the swipe settles on a new tab (Android, iOS 17.4+). Vertical scrolling inside a page never changes the tab, and a horizontal rail inside a page scrolls first. Tap a tab and the pages glide to it; tap the active tab again to scroll its page back to the top. On a trackpad, swipe horizontally with two fingers; with swipeWithMouse, drag the pages with the mouse (past a quarter of the width or a quick flick moves one page, a short drag springs back, and the click after a drag never reaches a row). With the keyboard, Tab reaches the selected tab, Left / Right move between tabs (mirrored in RTL, disabled tabs skipped, wrapping at the ends), Home / End jump, and in manual mode Enter or Space selects the focused tab; Tab again moves into the page, and when a swipe carries focus off screen it moves to the new tab instead of getting lost. When the tabs overflow, the row scrolls sideways with edge fades and keeps the active tab in view. Under reduced motion taps jump instead of sliding.

## Installation

```bash
npx wingo-ui@latest add swipeable-tabs
```

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/swipeable-tabs` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/swipeable-tabs.tsx`

npm dependencies: `npm i motion`

Wingo UI dependencies (installed automatically):

- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.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
import { useState } from "react"
import { SwipeableTabs } from "@/components/ui/swipeable-tabs"

const CLIENTS = [
  { name: "Tech Solutions Inc.", city: "New York", status: "active" },
  { name: "Summit Logistics", city: "Boston", status: "active" },
  { name: "Greenleaf Pharmacy", city: "Portland", status: "overdue" },
  { name: "Bistro 21", city: "Chicago", status: "prospect" },
]

function List({ status }: { status?: string }) {
  const rows = CLIENTS.filter((c) => !status || c.status === status)
  return (
    <ul className="divide-y divide-foreground/10 px-4">
      {rows.map((c) => (
        <li key={c.name} className="flex min-h-14 flex-col justify-center">
          <span className="text-[15px] font-medium">{c.name}</span>
          <span className="text-[13px] text-muted-foreground">{c.city}</span>
        </li>
      ))}
    </ul>
  )
}

export function Clients() {
  const [tab, setTab] = useState("all")
  return (
    <div className="flex h-dvh flex-col">
      <SwipeableTabs
        value={tab}
        onValueChange={setTab}
        aria-label="Clients"
        options={[
          { value: "all", label: "All", badge: 4, content: <List /> },
          { value: "active", label: "Active", badge: 2, content: <List status="active" /> },
          { value: "overdue", label: "Overdue", badge: 1, content: <List status="overdue" /> },
          { value: "prospects", label: "Prospects", content: <List status="prospect" /> },
        ]}
      />
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `SwipeableTabsOption[]` |  | The tabs and their pages: { value, label, icon?, badge?, disabled?, content }. A number badge renders a small count (hidden at 0), any other node renders as is. A disabled tab is skipped by the arrows and a swipe never settles on it. |
| `value?` | `string` |  | The selected tab, controlled. A change that arrives mid-swipe is applied after the swipe settles. |
| `defaultValue?` | `string` | `""` | The tab selected at first, uncontrolled. Empty selects the first enabled option. |
| `onValueChange?` | `(value: string) => void` |  | Fires when a swipe settles on a new page or a tab is pressed (or selected with the arrow keys). |
| `variant?` | `"underline" \| "pill" \| "segmented"` | `"underline"` | The W1 tabs looks: a 2px underline under the label, a tinted capsule, or a raised thumb in a tinted track. Each one stretches between tabs while you swipe. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Trigger height 32 / 40 / 48px with matching type and icons. On touch screens md grows to 44px and sm gets a 44px hit area. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Color of the indicator, the active label and the active count badge. neutral is black in light mode and white in dark mode. |
| `color?` | `string` | `""` | Any CSS color for the indicator and the active label. Replaces the tone; leave empty to use it. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape of the pill and the segmented track and thumb. Scales with the size; full makes capsules. |
| `fullWidth?` | `boolean` | `true` | Tabs fill the row, the phone pattern: pills and segments in equal cells, underline labels evenly spaced. Off, they size to their labels and the row scrolls sideways with edge fades when they overflow. |
| `layout?` | `"fill" \| "auto"` | `"fill"` | fill: the tabs fill their parent's height and every page is its own vertical scroller that keeps its scroll position, like an app screen. auto: pages take their content height and the tabs are as tall as the tallest page (settings sections, short forms). |
| `swipe?` | `boolean` | `true` | Off: tabs only, the pages swap with a short fade like W1 tabs and the indicator springs between tabs. |
| `swipeWithMouse?` | `boolean` | `false` | A mouse drag swipes the pages too. Off by default so dragging over text still selects it; touch and trackpads always swipe. |
| `lazy?` | `boolean` | `true` | A page mounts the first time it is active or next to the active one, so the neighbour is ready before you swipe to it. |
| `keepMounted?` | `boolean` | `true` | Visited pages stay mounted and keep their scroll position and what was typed. Off, pages more than one page away unmount after a swipe settles. |
| `scrollToTopOnReselect?` | `boolean` | `true` | Tapping the active tab scrolls its page back to the top (layout="fill"). |
| `activationMode?` | `"automatic" \| "manual"` | `"automatic"` | automatic: the arrow keys select as they move. manual: the arrows only move focus and Enter or Space selects. |
| `haptics?` | `boolean` | `true` | A "selection" tick through lib/haptics when a swipe settles on a new tab. Never on taps, keys or mount. |
| `aria-label?` | `string` | `"Tabs"` | Names the tab list for screen readers. Pass aria-labelledby instead when a visible heading names it. |
| `labels?` | `Partial<SwipeableTabsLabels>` |  | Strings read to screen readers: { swipeHint } describes the gesture on the tab list while swiping is on. The English default is "Swipe the content left or right to switch tabs." |
| `classNames?` | `Partial<Record<"list" \| "trigger" \| "icon" \| "label" \| "badge" \| "indicator" \| "panels" \| "panel", string>>` |  | Extra classes for the inner parts, e.g. { list: "px-4" } to inset pills from the screen edge. |
| `className?` | `string` |  | Extra classes for the root, merged last. Override the tone vars here, e.g. [--tone:var(--info)]. |

## Parts

### DEFAULT_SWIPEABLE_TABS_LABELS (constant)

The English screen reader copy: swipeHint "Swipe the content left or right to switch tabs."

### SwipeableTabsRoot (component)

The parts API root: takes the state (value, defaultValue, onValueChange), the look (variant, size, tone, color, radius, fullWidth), layout and behavior props, labels and classNames. Put a SwipeableTabsList and a SwipeableTabsPanels inside. Props: SwipeableTabsRootProps.

### SwipeableTabsList (component)

The tablist with the moving indicator. Holds the triggers; loop (default true) wraps the arrow keys. Props: ComponentProps<"div"> & { loop?: boolean }.

### SwipeableTabsTrigger (component)

One tab. data-state is active while the indicator is over it. Props: { value: string; icon?: ReactNode; badge?: number | ReactNode; disabled?: boolean; children }.

### SwipeableTabsPanels (component)

The swipe viewport: a native horizontal scroll-snap container. Its panels must be in the same order as the triggers. Takes div props.

### SwipeableTabsPanel (component)

One page. Mounted per lazy / keepMounted, inert while off screen (focus inside moves to the tab on screen), focusable only when it holds nothing focusable. Props: { value: string; children: ReactNode }.

### useSwipeableTabs (hook)

The state of the nearest tabs, for custom parts: progress is the page position (1.5 = half way between the second and third page) for a header that fades or a page counter. Signature: () => { value: string; index: number; progress: MotionValue<number>; select: (value: string) => void }.

## Examples

- **Pill folders with unread badges**: A messages screen: swipe between folders, the pill stretches from one to the next.
- **Segmented agenda**: Day / Week / Month as a segmented control whose thumb follows the page.
- **Eight categories that overflow**: fullWidth={false}: the tab row scrolls with edge fades and keeps the active tab in view.
- **Lazy panels and a mount log**: Panels mount when they are active or next to it; turn keepMounted off to unmount far pages.
- **Swipe on and off**: The same tabs with native swiping, and with swipe={false} where panels fade.
- **Brand underline with icons**: tone="brand": an orange underline and label, with icons and a count on Inbox.
- **Auto height settings**: layout="auto" in a card: the form keeps what you typed when you switch sections.

## See also

- [Tabs](https://wingo-ui.com/components/tabs) ([Markdown](https://wingo-ui.com/components/tabs.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Carousel](https://wingo-ui.com/components/carousel) ([Markdown](https://wingo-ui.com/components/carousel.md))
- [Pull To Refresh](https://wingo-ui.com/components/pull-to-refresh) ([Markdown](https://wingo-ui.com/components/pull-to-refresh.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))

---

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