Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Mobile UI in React
  4. Tailwind Touch Manipulation: touch-action for Every Control
Auf Deutsch lesen
  1. Blog
  2. Mobile UI in React
  3. Tailwind Touch Manipulation: touch-action for Every Control
Mobile UI in React
Mobile UI in React

Tailwind Touch Manipulation: touch-action for Every Control

SR
Serban Rusu · Founder of Wingo UI
Oct 13, 2026 · 9 min read

On this page

0%
  1. What does touch-action actually control?
  2. Which Tailwind touch-action utilities are there?
  3. What does touch-manipulation fix on buttons?
  4. How do you handle the tap highlight color in Tailwind?
  5. When should you use touch-none in Tailwind?
  6. When should you use touch-pan-y or touch-pan-x?
  7. How do sheets drag and scroll at the same time?
  8. Which class does each control need?
  9. How do you test touch-action?
  10. Where should you start?
  11. FAQ
    1. What does touch-manipulation do in Tailwind?
    2. Is there still a 300ms tap delay on mobile?
    3. When should I use touch-none in Tailwind?
    4. How do I remove the tap highlight color in Tailwind?
    5. Can I combine touch-pan-y and touch-pinch-zoom in Tailwind?

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.

Published Oct 13, 2026

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 series; the pillar guide, 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 (opens in a new tab)). 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 (opens in a new tab)):

Classtouch-actionUse it on
touch-autoautothe default; reset a value at a breakpoint
touch-manipulationmanipulationbuttons, links, tabs, steppers, toggles
touch-nonenonesliders, signature pads, canvases, maps
touch-pan-ypan-ythings you swipe sideways: rows, cards, carousels
touch-pan-xpan-xthings you drag vertically inside a horizontal scroller
touch-pan-left, touch-pan-rightpan-left, pan-rightone-way horizontal gestures
touch-pan-up, touch-pan-downpan-up, pan-downone-way vertical gestures
touch-pinch-zoompinch-zoomcombined 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 (opens in a new tab)). WebKit's own post on fast tapping (opens in a new tab) (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 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>
);
}
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
$ npx wingo-ui@latest add button
FreeButton docs

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 (opens in a new tab)).

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 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
/>
);
}
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
$ npx wingo-ui@latest add slider
ProSlider docs

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 and 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 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, 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.

Tap Filters, scroll the list inside the sheet, then drag down from its header: the list scrolls natively and the header drags the sheet
$ npx wingo-ui@latest add drawer
FreeDrawer docs

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 covers snap points and the keyboard.

Which class does each control need?

ControlClasses
Button, link, tab, toggle, menu itemtouch-manipulation select-none and an active: style
Stepper or any button tapped repeatedlytouch-manipulation, required here
Slider, signature pad, canvas, crop handletouch-none on the smallest element that works
Row, card or slide you swipe sidewaystouch-pan-y (or Motion's drag="x")
Vertical drag inside a horizontal scrollertouch-pan-x
Scroller inside a sheet or dialogleave touch-action alone, add overscroll-contain
html or bodynothing; 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.

Components in this post

  • Button

    The React button component every screen starts with: five variants, six tones, all sizes, and a loading state that never jumps.

    Free
  • Slider

    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.

    Pro
  • Drawer

    A React drawer and bottom sheet built on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

    Free

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.

  • Tailwind CSS
  • Touch
  • Mobile
  • CSS
  • Accessibility

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Comparisons and alternatives

Chakra UI vs shadcn in 2026: Style Props or Tailwind?

Chakra UI vs shadcn in October 2026: style props on Emotion or Tailwind source you own. Theming, Turbopack, Server Components and phones, with code.

SRSerban Rusu·Oct 16, 2026·10 min read
Comparisons and alternatives

Wingo UI vs shadcn/ui: Same Model, Different Scope

Wingo UI vs shadcn/ui, checked in October 2026: the same copy-the-source model compared on license, price, mobile behavior, updates and AI agent tooling.

SRSerban Rusu·Oct 13, 2026·9 min read
Comparisons and alternatives

14 shadcn Alternatives in 2026: Price, Mobile and MCP

14 shadcn alternatives checked in October 2026: free and paid UI libraries by license, price, mobile behavior and MCP support, with a pick per use case.

SRSerban Rusu·Oct 9, 2026·20 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI