# Tailwind Touch Manipulation: touch-action for Every Control

> Tailwind touch manipulation explained: which touch-action utility each button, slider, swipe row and sheet needs, and the tap and zoom bugs each value fixes.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 13, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/tailwind-touch-manipulation

## TL;DR

Put touch-manipulation on everything you tap, such as buttons, links, tabs and steppers: it keeps panning and pinch zoom but turns off double-tap zoom, so quick taps never zoom the page. Use touch-none only on surfaces where you handle every direction yourself, like a slider or a signature pad, and touch-pan-y on things you swipe sideways, so a vertical swipe still scrolls. Tailwind CSS v4's preflight already makes the tap highlight transparent, so every pressable needs its own pressed state.

Tap a plus button three times fast on an iPhone and the page zooms in instead of counting to three. Drag a slider thumb and the page scrolls along with it. Swipe a carousel sideways and the whole screen moves up. Each of these is one missing CSS property, `touch-action`, and Tailwind touch manipulation utilities set it per element: `touch-manipulation`, `touch-none`, `touch-pan-y` and seven more. This tutorial goes through what each value does, which controls need which one, and the tap highlight and zoom bugs they fix, with the classes we ship in the Wingo UI Button, Slider and Drawer.

It belongs to our [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui) series; the pillar guide, [Mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components), puts these rules next to touch targets and safe areas.

## What does touch-action actually control?

Which gestures the browser handles itself when a finger lands on an element. By default the browser owns panning (scrolling) and pinch zoom, and once it starts a pan, your pointer events stop: you get a `pointercancel` and no more `pointermove` ([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)). That is why a drag built on pointer events works with a mouse and dies on a phone the moment the finger moves.

Three rules from the spec explain most surprises:

- **It is not inherited, but it still reaches children.** When a gesture starts, the browser intersects the `touch-action` of the touched element and its ancestors, up to the nearest scroll container. A `touch-manipulation` on a button covers the icon inside it.
- **It stops at the scroll container.** Values set above the element that scrolls do not apply to a touch inside it. A sheet with `touch-action: none` can still have a list that scrolls natively, which we will use for the Drawer below.
- **It is read when the gesture starts.** Changing it mid-drag does nothing for that drag.

## Which Tailwind touch-action utilities are there?

Ten, each mapping to one value ([Tailwind docs](https://tailwindcss.com/docs/touch-action)):

| Class | `touch-action` | Use it on |
|---|---|---|
| `touch-auto` | `auto` | the default; reset a value at a breakpoint |
| `touch-manipulation` | `manipulation` | buttons, links, tabs, steppers, toggles |
| `touch-none` | `none` | sliders, signature pads, canvases, maps |
| `touch-pan-y` | `pan-y` | things you swipe sideways: rows, cards, carousels |
| `touch-pan-x` | `pan-x` | things you drag vertically inside a horizontal scroller |
| `touch-pan-left`, `touch-pan-right` | `pan-left`, `pan-right` | one-way horizontal gestures |
| `touch-pan-up`, `touch-pan-down` | `pan-up`, `pan-down` | one-way vertical gestures |
| `touch-pinch-zoom` | `pinch-zoom` | combined with a pan value to keep zoom |

The pan and zoom classes compose. In Tailwind CSS 4.3.3 (the version we checked in October 2026), each one sets a variable such as `--tw-pan-y`, and a single declaration reads them all: `touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)`. So `touch-pan-y touch-pinch-zoom` gives you `pan-y pinch-zoom`, a sideways swipe surface that still lets people zoom. Variants work as usual, for example `touch-pan-y md:touch-auto`.

## What does touch-manipulation fix on buttons?

Double-tap zoom. `manipulation` allows panning and pinch zoom and turns off the rest, chiefly double-tap to zoom. MDN describes it as an alias for `pan-x pan-y pinch-zoom`, and notes that without double-tap zoom the browser no longer needs to delay clicks.

The 300ms double tap zoom delay is mostly history. Chrome dropped it in version 32 for pages with `width=device-width`, and Safari followed in iOS 9.3 ([Chrome's post](https://developer.chrome.com/blog/300ms-tap-delay-gone-away)). WebKit's own [post on fast tapping](https://webkit.org/blog/5610/more-responsive-tapping-on-ios/) (December 2015) adds the detail that still matters: that fast path applies only while the page is at its initial scale. Once someone has zoomed in, Safari waits for a possible second tap again. `touch-action: manipulation` keeps taps immediate at every zoom level.

The bug you still see today is the zoom itself. A stepper's plus button, a calculator key or a "load more" tapped twice in a row can register as a double tap and zoom the page. Put the class on the control:

```tsx
"use client";

import { useState } from "react";
import { Plus } from "lucide-react";

export function GuestCount() {
  const [count, setCount] = useState(1);
  return (
    <div className="flex items-center gap-3">
      <span aria-live="polite">{count} guests</span>
      <button
        type="button"
        aria-label="Add a guest"
        onClick={() => setCount((n) => n + 1)}
        className="grid size-11 touch-manipulation select-none place-items-center rounded-full bg-foreground/5 active:bg-foreground/10"
      >
        <Plus aria-hidden className="size-5" />
      </button>
    </div>
  );
}
```

Put it on controls, not on `html` or `body`. Double-tap zoom on text is how some people read small print; turning it off for the whole page takes that away to fix a problem only your controls have.

The free Wingo UI [Button](https://wingo-ui.com/components/button) puts `touch-manipulation`, `select-none` and the tap highlight reset in its own base classes, so a copied button keeps them in a project with any global stylesheet:

```tsx
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";

export function AddGuest({ onAdd }: { onAdd: () => void }) {
  return (
    <Button variant="soft" size="icon" radius="full" aria-label="Add a guest" onClick={onAdd}>
      <Plus />
    </Button>
  );
}
```

> Live demo (Button): Tap the button on a phone: it dips on a spring and shows its loading state without changing width, and quick repeated taps never zoom the page. Try it at [Button](https://wingo-ui.com/components/button) and install it with `npx wingo-ui@latest add button`.

## How do you handle the tap highlight color in Tailwind?

Mostly by adding a pressed state, because Tailwind already removed the highlight. `-webkit-tap-highlight-color` sets the translucent box that Safari and Chromium draw over a tapped link or button. It is non-standard, inherited, and supported in WebKit and Blink, not Firefox ([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color)).

Tailwind CSS v4's preflight sets `-webkit-tap-highlight-color: transparent` on `html` (in 4.3.3, the comment reads "Disable tap highlights on iOS"), and because the property is inherited, every element in a Tailwind v4 app starts without the highlight. That has a cost people miss: a plain `<a>` or `<button>` with no `active:` style gives no visual sign of a tap at all.

So in a Tailwind v4 app the rules run the other way around:

- **Every pressable gets a pressed state.** `active:bg-foreground/10`, `active:scale-[0.97]`, or a spring press like the Button's.
- **Components you share set it themselves.** Code copied into a project without preflight, or one that resets the property, flashes gray again. Use the arbitrary property `[-webkit-tap-highlight-color:transparent]` in the component, or define a utility once:

```css
/* app/globals.css */
@utility tap-highlight-none {
  -webkit-tap-highlight-color: transparent;
}
```

- **Do not remove it without a replacement.** If a control has no pressed style, a visible tap highlight is better than nothing.

## When should you use touch-none in Tailwind?

When your code handles every direction of movement on that element and you are fine with a swipe that starts there never scrolling the page. That fits a slider, a signature pad, a drawing canvas, a map or a crop handle. It is the wrong call for anything large: a full-screen `touch-none` element traps people who try to scroll past it, and MDN warns that `none` also blocks pinch zoom, which people with low vision rely on.

The Wingo UI [Slider](https://wingo-ui.com/components/slider) shows how to keep it small. The root is the only `touch-none` part, a thin row with a 44px hit area added by a pseudo-element on coarse pointers, so a drag on it never scrolls the page while the rest of the form scrolls normally:

```tsx
"use client";

import { useState } from "react";
import { Slider } from "@/components/ui/slider";

export function PriceFilter() {
  const [price, setPrice] = useState([500, 2000]);
  return (
    <Slider
      label="Price"
      value={price}
      onValueChange={setPrice}
      min={0}
      max={5000}
      step={50}
      locale="en-US"
      format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
      showValueLabel
    />
  );
}
```

> Live demo (Slider): Drag a thumb on a phone: the page stays still while the value bubble follows your finger. Then swipe up next to the slider and the page scrolls. Try it at [Slider](https://wingo-ui.com/components/slider) and install it with `npx wingo-ui@latest add slider`.

The thumbs add `touch-manipulation` on top, so a quick double tap on a thumb never zooms either.

## When should you use touch-pan-y or touch-pan-x?

When your element moves along one axis and the page scrolls along the other. A row you swipe left to delete, a card you swipe away, or a carousel slide should all have `touch-pan-y`: the browser keeps vertical scrolling, and a horizontal move goes to your pointer handlers.

```tsx
"use client";

import { useRef, type PointerEvent, type ReactNode } from "react";

export function SwipeRow({ children, onDelete }: { children: ReactNode; onDelete: () => void }) {
  const startX = useRef<number | null>(null);
  const offset = useRef(0);

  function down(event: PointerEvent<HTMLLIElement>) {
    startX.current = event.clientX;
    event.currentTarget.setPointerCapture(event.pointerId);
  }
  function move(event: PointerEvent<HTMLLIElement>) {
    if (startX.current === null) return;
    offset.current = Math.min(0, event.clientX - startX.current);
    event.currentTarget.style.transform = `translateX(${offset.current}px)`;
  }
  function end(event: PointerEvent<HTMLLIElement>) {
    if (offset.current < -120) onDelete();
    startX.current = null;
    offset.current = 0;
    event.currentTarget.style.transform = "";
  }

  return (
    <li
      onPointerDown={down}
      onPointerMove={move}
      onPointerUp={end}
      onPointerCancel={end}
      className="touch-pan-y select-none"
    >
      {children}
    </li>
  );
}
```

With your own pointer handlers like these, the class is required: without it the browser treats the touch as a possible scroll, and a move with any vertical drift ends in `pointercancel`. `setPointerCapture` is the other half, so the row keeps getting moves after the finger slides off it. The handlers write the transform straight to the element instead of calling `setState` on every move. Motion does this for you. Its `drag="x"` writes `touch-action: pan-y` as an inline style (`pan-x` for `drag="y"`, `none` for `drag`), and an inline style wins over any class you add. Our [React swipe to delete](https://wingo-ui.com/blog/react-swipe-to-delete) and [React swipe cards](https://wingo-ui.com/blog/react-swipe-cards) tutorials build both patterns.

`touch-pan-x` is the mirror case and rarer: a vertical drag, such as a reorder handle, inside a row that scrolls sideways. For sortable lists, the [dnd kit mobile tutorial](https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag) compares the `touch-action` and sensor combinations that let a long press drag while a swipe still scrolls.

## How do sheets drag and scroll at the same time?

By relying on the rule that `touch-action` stops at the scroll container. vaul, the library under the free [Drawer](https://wingo-ui.com/components/drawer), sets `touch-action: none` on the whole sheet, so a drag anywhere on its header or footer moves the sheet. The Drawer's body is its own scroll container (`overflow-y-auto overscroll-contain`), so a touch in the body only intersects values up to the body itself, and the list inside scrolls natively. vaul then moves the sheet only once the list is back at its top.

> Live demo (Drawer): Tap Filters, scroll the list inside the sheet, then drag down from its header: the list scrolls natively and the header drags the sheet. Try it at [Drawer](https://wingo-ui.com/components/drawer) and install it with `npx wingo-ui@latest add drawer`.

`overscroll-contain` does the other half: when the list reaches its end, the scroll does not chain into the page behind the sheet. `touch-action` does not control scroll chaining at all; `overscroll-behavior` does. For a sheet whose content has its own horizontal gestures, such as a carousel or a map, pass `handleOnly` so only the handle drags it. The [React bottom sheet guide](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) covers snap points and the keyboard.

## Which class does each control need?

| Control | Classes |
|---|---|
| Button, link, tab, toggle, menu item | `touch-manipulation select-none` and an `active:` style |
| Stepper or any button tapped repeatedly | `touch-manipulation`, required here |
| Slider, signature pad, canvas, crop handle | `touch-none` on the smallest element that works |
| Row, card or slide you swipe sideways | `touch-pan-y` (or Motion's `drag="x"`) |
| Vertical drag inside a horizontal scroller | `touch-pan-x` |
| Scroller inside a sheet or dialog | leave `touch-action` alone, add `overscroll-contain` |
| `html` or `body` | nothing; never `touch-none` or `touch-manipulation` here |

## How do you test touch-action?

Read the computed value first: in Safari's Web Inspector or Chrome DevTools, the Computed panel shows `touch-action` on the element, and a parent's value matters too, up to the nearest scroller. Chrome's device mode emulates touch, so you can check that a drag on a `touch-none` surface does not scroll. Double-tap zoom and tap highlights need a real phone: tap your stepper rapidly on an iPhone and on an Android phone, zoom the page in, and tap again.

In Playwright, a context with `hasTouch: true` lets you call `page.touchscreen.tap()`, which is enough to check that clicks fire. It does not reproduce the browser's gesture recognition, so keep the device pass for the zoom behavior.

## Where should you start?

Search your codebase for `onClick` on elements without `touch-manipulation` and for drag handlers without a `touch-*` class; those two lists are usually the whole fix. Then check that every pressable has a pressed state, since Tailwind v4 already hid the tap highlight. If you would rather start from components that carry these classes, the Button and the Drawer are free: `npx wingo-ui@latest add button drawer`. The Slider is part of [Wingo UI Pro](https://wingo-ui.com/pricing).

## Components in this post

- [Button](https://wingo-ui.com/components/button) (Free): The React button component every screen starts with: five variants, six tones, all sizes, and a loading state that never jumps. Install: `npx wingo-ui@latest add button`
- [Slider](https://wingo-ui.com/components/slider) (Pro): A React slider for a number or a range: any number of thumbs, labelled marks, a value bubble, vertical mode, tones and finger-sized thumbs. Install: `npx wingo-ui@latest add slider`
- [Drawer](https://wingo-ui.com/components/drawer) (Free): A React drawer and bottom sheet built on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: `npx wingo-ui@latest add drawer`

## FAQ

### What does touch-manipulation do in Tailwind?

It sets touch-action: manipulation, which lets the browser pan and pinch zoom but turns off other gestures such as double-tap zoom. Without double-tap zoom the browser has no reason to wait for a second tap, so taps on that element fire at once.

### Is there still a 300ms tap delay on mobile?

Not on pages with width=device-width at their initial zoom: Chrome removed it in version 32 and Safari in iOS 9.3. WebKit documented that its fast tap applies only at the initial scale, and rapid taps can still trigger double-tap zoom, which is what touch-manipulation turns off per element.

### When should I use touch-none in Tailwind?

Only on elements where your code handles every direction of movement, such as a slider, a signature pad, a canvas or a map. A swipe that starts there never scrolls or zooms the page, so keep those elements small or give the page another place to scroll.

### How do I remove the tap highlight color in Tailwind?

In Tailwind CSS v4 you usually do not have to: preflight sets -webkit-tap-highlight-color: transparent on html, and the property is inherited. Without preflight use the arbitrary property [-webkit-tap-highlight-color:transparent], and always add an active or pressed style so a tap still shows.

### Can I combine touch-pan-y and touch-pinch-zoom in Tailwind?

Yes. In Tailwind CSS v4 the pan and pinch utilities write separate CSS variables that one touch-action declaration reads, so touch-pan-y touch-pinch-zoom compiles to touch-action: pan-y pinch-zoom.

---

Source: https://wingo-ui.com/blog/tailwind-touch-manipulation
