# React Swipeable Tabs: Pages That Follow the Thumb

> Build React swipeable tabs on native scroll snap: an indicator that tracks the finger, vertical scroll that never flips the tab, keyboard support, no old deps.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-swipeable-tabs

## TL;DR

Build React swipeable tabs by putting each tab's page side by side in a horizontal CSS scroll snap container, so a sideways swipe moves one page with the browser's own momentum while the indicator reads the scroll position. Make each page its own vertical scroller so a vertical scroll never changes the tab, and keep the tab row a real WAI-ARIA tablist with arrow keys, Home and End. Wingo UI's Swipeable Tabs (Pro) packages this as one React 19 component, without the React 17 era react-swipeable-views dependency.

You want the tabs from native apps on the web: the pages sit side by side, a sideways swipe pulls the next one in under your thumb, and the indicator in the tab row slides along as the pages move. React swipeable tabs look simple until a vertical scroll flips the tab or the indicator lags a frame behind the finger. This guide covers what a web version has to get right (the swipe, the indicator, vertical scrolling, keyboard, reduced motion) with the [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) component from Wingo UI. We build that library, so we are not neutral.

## What are swipeable tabs, and when do they fit?

Swipeable tabs are a tab set where the panels are also pages in a horizontal pager. Tapping a tab slides to its page; swiping the content moves to the neighbor and selects its tab. They fit screens where the tabs are parallel views of the same thing and people move between neighbors a lot: folders of a mail client, Active / Overdue / Prospects in a CRM list, Day / Week / Month in an agenda.

They fit less well when a page holds horizontal gestures of its own (a map, a chart you scrub, a photo carousel) or when the tabs are unrelated sections. For a settings page with four forms, plain [Tabs](https://wingo-ui.com/components/tabs) are the better control, and a choice that only changes the data on screen, like a time range, is a [Segmented Control](https://wingo-ui.com/components/segmented-control). The [React segmented control guide](https://wingo-ui.com/blog/react-segmented-control) explains that split in detail.

## Why not use react-swipeable-views?

Because it still declares React 17 as its newest supported version, and the browser now does its job natively. For years the way to swipe between tabs in React was react-swipeable-views, the swipeable views package behind the full-width example in Material UI's v4 [Tabs demo](https://v4.mui.com/components/tabs/). Its latest release on [npm](https://www.npmjs.com/package/react-swipeable-views) is 0.14.2, published June 22, 2026, and its peer dependencies are still React 15, 16 and 17 (checked October 16, 2026). In a React 19 project, npm 7 and later stop the install with a peer dependency conflict unless you pass `--legacy-peer-deps`. The current [MUI Tabs docs](https://mui.com/material-ui/react-tabs/) no longer show a swipe demo (checked October 16, 2026).

A carousel library wired to the tab value works too, but then the drag, the axis decision and the momentum come from JavaScript instead of the browser's scroller. Adobe's React Aria docs take the native route in their [swipeable tabs example](https://react-aria.adobe.com/examples/swipeable-tabs), which builds on CSS scroll snapping, scroll-driven animations and anchor positioning. If your app already uses React Aria Components, start there. Wingo UI's component uses the same scroll snap pager and drives the indicator from a motion value instead.

## How do you add swipeable tabs in React?

Install the component, pass one option per tab with its page, and give the parent a height. The CLI also copies what the file imports: Badge, the Haptics, motion, tone and UI config helpers, and two small hooks:

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

Each object in `options` takes `value`, `label`, an optional `icon`, `badge` or `disabled`, and its page in `content`. The default `layout="fill"` makes the tabs fill their parent's height, so the parent needs one:

```tsx
"use client";

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 ClientList({ 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
        aria-label="Clients"
        value={tab}
        onValueChange={setTab}
        options={[
          { value: "all", label: "All", badge: 4, content: <ClientList /> },
          { value: "active", label: "Active", badge: 2, content: <ClientList status="active" /> },
          { value: "overdue", label: "Overdue", badge: 1, content: <ClientList status="overdue" /> },
          { value: "prospects", label: "Prospects", content: <ClientList status="prospect" /> },
        ]}
      />
    </div>
  );
}
```

`onValueChange` fires when a swipe settles on a new page, a tab is pressed or the arrow keys select one. A number in `badge` renders a count that hides at 0.

> Live demo (Swipeable Tabs): Swipe the client list sideways, or swipe with two fingers on a trackpad: the underline stretches between tabs as the pages move. Tap the active tab to scroll its page back to the top. Try it at [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) and install it with `npx wingo-ui@latest add swipeable-tabs`.

## How does the indicator track the swipe?

The indicator reads the scroller's position on every scroll event and never waits for React. The pages live in a native horizontal scroller with `scroll-snap-type: x mandatory`, and every page has `scroll-snap-stop: always`, so one swipe moves at most one page and the momentum is the browser's own. A passive scroll listener turns the position into one number, `progress`, a motion value where 0 is the first page and 1.5 is half way between the second and third.

Everything that moves is derived from that number. The tab row measures the triggers' offsets after each render (and again when fonts load or a badge grows), and the indicator's position and width are linear interpolations between the two tabs on either side of `progress`. The underline is a fixed 100px bar scaled to the label width, so it stays a transform; pills and the segmented thumb get a width, because scaling a capsule would stretch its rounded ends. None of this triggers a React render per frame. The label color flips at the half way point, which costs a render a handful of times per swipe.

The same number is available to your own parts through `useSwipeableTabs()`. Here is a thin progress bar under the tab row, built with the parts API:

```tsx
"use client";

import { motion, useTransform } from "motion/react";
import {
  SwipeableTabsList,
  SwipeableTabsPanel,
  SwipeableTabsPanels,
  SwipeableTabsRoot,
  SwipeableTabsTrigger,
  useSwipeableTabs,
} from "@/components/ui/swipeable-tabs";

const VIEWS = ["day", "week", "month"];

function PageProgress() {
  const { progress } = useSwipeableTabs();
  const scaleX = useTransform(progress, (p) => (p + 1) / VIEWS.length);
  return <motion.div style={{ scaleX }} className="h-0.5 origin-left bg-foreground/20" />;
}

export function Agenda() {
  return (
    <div className="flex h-dvh flex-col">
      <SwipeableTabsRoot defaultValue="week" variant="segmented">
        <SwipeableTabsList aria-label="Agenda" className="px-4">
          {VIEWS.map((view) => (
            <SwipeableTabsTrigger key={view} value={view} className="capitalize">
              {view}
            </SwipeableTabsTrigger>
          ))}
        </SwipeableTabsList>
        <PageProgress />
        <SwipeableTabsPanels>
          {VIEWS.map((view) => (
            <SwipeableTabsPanel key={view} value={view}>
              <p className="p-4 text-muted-foreground">Events for this {view}</p>
            </SwipeableTabsPanel>
          ))}
        </SwipeableTabsPanels>
      </SwipeableTabsRoot>
    </div>
  );
}
```

The panels must be in the same order as the triggers, since page 2 of the scroller is tab 2.

## When does a swipe count as a tab change?

When the scroller has stopped on a page and no finger is down. Committing on every scroll event is the easiest bug to ship in a hand-rolled scroll snap tabs setup, and the component guards against three versions of it:

- **Settling.** It listens for `scrollend` and, for browsers without it, settles after 150ms with no scroll events. A settle is ignored while a touch is still down, so a finger resting mid-swipe never commits a tab.
- **Programmatic slides.** Tapping tab 4 while on tab 1 smooth-scrolls past pages 2 and 3. The component remembers the target and ignores settles on the pages in between, so `onValueChange` fires once, for tab 4, instead of three times. The haptic tick only plays for settles that came from a finger, a trackpad or a mouse drag.
- **Fractional widths.** `clientWidth` is rounded while snap points are not, so in a container 393.5px wide `scrollLeft` lands a fraction of a pixel off `page * width`, and the drift grows by up to half a pixel per page. An exact equality check can miss the later pages; the component allows for that drift.

A disabled tab is skipped here too: a swipe that lands on its page carries on to the next enabled one.

## How do you stop a vertical scroll from changing the tab?

This is the classic bug in hand-rolled tabs with swipe gesture support: you scroll a long list, your thumb drifts sideways, and the screen jumps to the next tab, because a JavaScript handler decided "horizontal" too early.

Swipeable Tabs leaves the decision to the browser. With `layout="fill"`, each page is its own vertical scroller (`overflow-y: auto` with `overscroll-behavior-y: contain`) inside the horizontal pager, and the browser gives each gesture to the scroller that matches its direction. A mostly vertical drag scrolls the list, a mostly horizontal one turns the page. Each page also keeps its own scroll position, so swiping away and back lands where you left off.

Two more cases come up on real screens:

- **A horizontal rail inside a page.** A row of chips or cards that overflows sideways scrolls first. Once the rail reaches its end, the next swipe turns the page.
- **A surface that owns horizontal drags.** A map, a range slider or a signature pad should never turn the page. Mark it with `data-no-swipe` and horizontal pans that start on it stay with it:

```tsx
<SwipeableTabsPanel value="route">
  <div data-no-swipe className="h-72">
    {/* your map or chart: horizontal drags stay here */}
  </div>
</SwipeableTabsPanel>
```

On a desktop, a two-finger trackpad swipe turns the pages. A mouse drag does not by default, so dragging over text still selects it; with `swipeWithMouse`, a drag past a quarter of the width or a quick flick moves one page.

> Live demo (Swipeable Tabs): The same tabs with variant="segmented": the raised thumb slides between segments in step with the pages, and scrolling a list up and down never changes the tab. Try it at [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) and install it with `npx wingo-ui@latest add swipeable-tabs`.

## What happens with the keyboard and screen readers?

The tab row follows the [WAI-ARIA tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): a `tablist` of `tab` buttons with `aria-selected` and `aria-controls`, and a `tabpanel` per page labelled by its tab. Tab lands on the selected tab, Left and Right move between tabs (mirrored in right-to-left layouts, skipping disabled tabs, wrapping at the ends), Home and End jump, and Tab again moves into the page.

The APG recommends selecting on focus (automatic activation) when panels appear without a noticeable delay. That is the default here, because the neighbor pages are already mounted. If a page loads data when it opens, set `activationMode="manual"`: the arrows only move focus and Enter or Space selects.

Swiping adds two details. Off-screen pages are `inert`, so Tab never lands on a field you cannot see, and when a swipe carries the focused field away, focus moves to the tab now on screen. The tab list also carries a hidden description, "Swipe the content left or right to switch tabs.", which you can translate through the `labels` prop.

## How do swipeable tabs behave under reduced motion?

A swipe is direct manipulation: the page is under your finger, so it moves with it whatever the setting. What changes under `prefers-reduced-motion: reduce` is everything the component animates on its own. A tap on a tab jumps to the page instead of gliding there, the indicator jumps with it, the tap scale on triggers is off, and pages that swap without swiping only fade. The setting comes from `usePrefersReducedMotion` in `lib/motion`, which is hydration safe; the [prefers-reduced-motion guide](https://wingo-ui.com/blog/prefers-reduced-motion-react) explains why that matters.

## Which Swipeable Tabs props matter most?

Most screens only need `options`, `value` and `onValueChange`; the rest are defaults you change for one specific screen.

| Prop | Default | What it does |
|---|---|---|
| `variant` | `"underline"` | Underline, pill or segmented thumb; each one stretches between tabs while you swipe |
| `layout` | `"fill"` | `fill`: tabs fill the parent and each page scrolls on its own. `auto`: pages take their content height |
| `fullWidth` | `true` | Tabs share the row. Off, they size to their labels and the row scrolls with edge fades |
| `swipe` | `true` | Off: plain tabs, pages swap with a short fade |
| `swipeWithMouse` | `false` | A mouse drag swipes the pages too |
| `lazy` | `true` | A page mounts the first time it is active or next to the active one |
| `keepMounted` | `true` | Visited pages stay mounted with their scroll position and typed input |
| `activationMode` | `"automatic"` | `manual`: arrows move focus, Enter or Space selects |
| `scrollToTopOnReselect` | `true` | Tapping the active tab scrolls its page to the top |
| `haptics` | `true` | A selection tick through the [Haptics](https://wingo-ui.com/components/haptics) helper when a swipe settles on a new tab |

`size`, `tone`, `color`, `radius` and `classNames` follow the same scale as the rest of the library. Touch screens get 44px triggers at the default size.

On Android the tick uses `navigator.vibrate`, and only after the page has seen a tap. On an iPhone the helper falls back to the system tick of a hidden native switch, which iOS 26.5 and later play only for the user's own tap on it, so a swipe settle stays silent there (as of October 2026; the [Web Vibration API guide](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback) has the sources). Treat the tick as a bonus, never as the only feedback.

## What goes wrong with swipeable tabs on real screens?

Four mistakes are easy to make when you wire this into an app, and each has a one-line fix.

- **No height on the parent.** With `layout="fill"` the tabs take `h-full`. Inside a parent without a height the pages collapse. Use a `flex h-dvh flex-col` screen, or switch to `layout="auto"` for tabs inside a card.
- **Heavy pages that all mount at once.** Keep `lazy` on: the first render mounts only the active page and its neighbors. If visited pages pile up, set `keepMounted={false}` and pages more than one away unmount after a swipe settles, losing their scroll position.
- **A controlled value that changes mid-swipe.** The component applies it after the swipe settles instead of fighting the finger, so do not "fix" it with a timeout.
- **Too many tabs.** The row scrolls sideways with edge fades once the labels overflow, and it keeps the active tab in view as you swipe. With the default `fullWidth`, overflowing underline labels shrink to a tight minimum gap first, so past four or five labels on a 360px phone set `fullWidth={false}` to give each label its normal padding.

## Where should you start?

If your screen only needs tabs that react to taps, start with the free Tabs (`npx wingo-ui@latest add tabs`). For pages that follow the thumb, Swipeable Tabs is part of [Wingo UI Pro](https://wingo-ui.com/pricing), and the live demo on its docs page works on your phone before you install anything. More patterns like this one are in the [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui) category and the [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components).

## Components in this post

- [Swipeable Tabs](https://wingo-ui.com/components/swipeable-tabs) (Pro): 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. Install: `npx wingo-ui@latest add swipeable-tabs`
- [Tabs](https://wingo-ui.com/components/tabs) (Free): React tabs with a sliding indicator that switch views of one page: underline for sections, pill for filters, segmented for a compact switch. Install: `npx wingo-ui@latest add tabs`
- [Segmented Control](https://wingo-ui.com/components/segmented-control) (Free): A React iOS-style segmented control with a raised thumb that slides and follows your finger. Install: `npx wingo-ui@latest add segmented-control`

## FAQ

### How do I swipe between tabs in React without a library?

Put one full-width panel per tab in a container with scroll-snap-type: x mandatory, read scrollLeft divided by clientWidth on scroll to move the indicator, and commit the new tab when the scroll settles. You still have to write the tab semantics, keyboard support, focus handling and the controlled value yourself.

### Does react-swipeable-views work with React 19?

Its latest release, 0.14.2 from June 2026, lists React 15, 16 and 17 as peer dependencies, so npm reports a peer dependency conflict in a React 19 project (npm registry, checked October 16, 2026). A native scroll snap container needs no extra package at all.

### Why does a vertical scroll switch my tabs?

Usually a JavaScript gesture handler claims the drag before the browser has decided its direction. When the pager is a native horizontal scroller and every page scrolls vertically on its own, the browser hands each gesture to the scroller that matches its direction, so a mostly vertical drag scrolls the page.

### Are swipeable tabs accessible?

They are when the tab row is a tablist of real buttons with aria-selected and aria-controls, the arrow keys move between tabs, and pages that are off screen are inert. The swipe is an extra: every tab must also open with a tap and with the keyboard.

### Is the Wingo UI Swipeable Tabs component free?

No, Swipeable Tabs is part of Wingo UI Pro. Tabs and the Segmented Control are free and install with npx wingo-ui@latest add tabs or add segmented-control.

---

Source: https://wingo-ui.com/blog/react-swipeable-tabs
