|

Touch Gestures in Playwright: Swipe, Tap, and Pinch

Mobile web and responsive apps live and die by touch. A button that works perfectly with a mouse click can break when a real finger taps it, and swipe-driven carousels, pull-to-refresh lists, and pinch-to-zoom maps have no mouse equivalent at all. In this guide you will learn how Playwright touch gestures swipe tap and pinch actually work under the hood, how to enable real touch emulation, and how to write reliable TypeScript tests for taps, swipes, drags, and multi-touch pinch using the Touchscreen API and CDP.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why Touch Testing Is Different From Clicking

When you call page.click(), Playwright dispatches mouse events: mousedown, mouseup, and click. Touch-enabled UIs listen for a completely different family of events: touchstart, touchmove, and touchend, plus the unified Pointer Events (pointerdown, pointermove, pointerup) with pointerType: "touch". A carousel that swipes on touchmove will simply ignore your mouse drag.

The fix has two parts. First, you must run inside a browser context that has touch enabled so that the page reports navigator.maxTouchPoints > 0 and your responsive media queries behave like a phone. Second, you drive interactions through page.touchscreen (the Touchscreen class) instead of page.mouse. Get either half wrong and your gesture test will silently pass against the desktop code path while the touch code path stays untested.

Enabling Touch: Device Emulation and hasTouch

The cleanest way to enable touch is to emulate a real device from Playwright’s built-in devices registry. Each descriptor sets the viewport, user agent, deviceScaleFactor, isMobile, and crucially hasTouch: true. You can apply it project-wide in playwright.config.ts or per test through browser.newContext().

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 7'] }, // sets hasTouch + isMobile + viewport
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 14'] },
    },
  ],
});

If you do not want a full device profile, enable touch explicitly. The hasTouch context option is what actually wires up the Touchscreen API; isMobile additionally toggles the mobile viewport meta and the mobile behaviours in Chromium.

import { test, expect } from '@playwright/test';

test('touch is enabled in this context', async ({ browser }) => {
  const context = await browser.newContext({
    hasTouch: true,
    isMobile: true,
    viewport: { width: 390, height: 844 },
  });
  const page = await context.newPage();
  await page.goto('https://example.com');

  const maxTouchPoints = await page.evaluate(() => navigator.maxTouchPoints);
  expect(maxTouchPoints).toBeGreaterThan(0);

  await context.close();
});

A common gotcha: isMobile is not supported in Firefox. If you target WebKit and Chromium for mobile, keep Firefox on a desktop-style project or it will throw at context creation. Use hasTouch alone when you only need the touch event surface without the mobile viewport quirks.

The Simplest Gesture: Tap

A tap is the touch equivalent of a click. Playwright gives you two ergonomic ways to tap. The high-level locator method locator.tap() is auto-waiting, retries actionability checks, and scrolls the element into view for you. The low-level page.touchscreen.tap(x, y) fires a raw touch sequence at absolute coordinates.

import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['Pixel 7'] });

test('tap a field and add a todo', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');

  // High-level: auto-waits, scrolls into view, asserts visibility
  await page.getByRole('textbox', { name: 'What needs to be done?' }).tap();
  await page.keyboard.type('Buy milk');
  await page.keyboard.press('Enter');

  await expect(page.getByText('Buy milk')).toBeVisible();
});

Reach for page.touchscreen.tap() only when you need a tap at a precise pixel that is not tied to a single element, such as dismissing an overlay by tapping empty canvas space. Remember that locator.tap() requires the context to have hasTouch: true; otherwise Playwright throws “The page does not support tap”, which is your signal that device emulation was never applied.

Building a Swipe Gesture

Playwright has no single swipe() method. A swipe is a touch press, a series of moves, and a release. You compose it from the primitives on page.touchscreen. The key is dispatching enough intermediate touchmove points that the page’s gesture recognizer registers velocity and direction rather than a teleport from start to end.

The most robust approach is to dispatch the raw touch events through dispatchEvent or to use the CDP Input.dispatchTouchEvent. For most carousels and sliders, however, a hand-rolled helper that emits interpolated touchmove events inside page.evaluate is the clearest and most portable solution.

import { Page } from '@playwright/test';

interface SwipeOptions {
  startX: number;
  startY: number;
  endX: number;
  endY: number;
  steps?: number;
}

// Dispatch a realistic swipe by emitting interpolated touch events.
async function swipe(page: Page, opts: SwipeOptions): Promise<void> {
  const { startX, startY, endX, endY, steps = 12 } = opts;

  await page.evaluate(
    async ({ startX, startY, endX, endY, steps }) => {
      const target = document.elementFromPoint(startX, startY) ?? document.body;

      const makeTouch = (x: number, y: number) =>
        new Touch({ identifier: 0, target, clientX: x, clientY: y });

      const fire = (type: string, x: number, y: number) => {
        const touch = makeTouch(x, y);
        const isEnd = type === 'touchend';
        target.dispatchEvent(
          new TouchEvent(type, {
            cancelable: true,
            bubbles: true,
            touches: isEnd ? [] : [touch],
            targetTouches: isEnd ? [] : [touch],
            changedTouches: [touch],
          }),
        );
      };

      fire('touchstart', startX, startY);
      for (let i = 1; i <= steps; i++) {
        const x = startX + ((endX - startX) * i) / steps;
        const y = startY + ((endY - startY) * i) / steps;
        fire('touchmove', x, y);
        await new Promise((r) => setTimeout(r, 16)); // ~60fps cadence
      }
      fire('touchend', endX, endY);
    },
    { startX, startY, endX, endY, steps },
  );
}

With the helper in place, a left swipe on a carousel becomes readable and intention-revealing. Compute the start and end coordinates from the element’s bounding box so the test stays resilient to viewport changes.

import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['Pixel 7'] });

test('swipe carousel to the next slide', async ({ page }) => {
  await page.goto('https://your-app.example/gallery');

  const carousel = page.getByTestId('carousel');
  const box = await carousel.boundingBox();
  if (!box) throw new Error('carousel not found');

  const midY = box.y + box.height / 2;
  await swipe(page, {
    startX: box.x + box.width * 0.85, // start near the right edge
    startY: midY,
    endX: box.x + box.width * 0.15,   // drag toward the left
    endY: midY,
  });

  await expect(page.getByTestId('slide-2')).toBeVisible();
});

If you only need a single-pointer drag and the target uses Pointer Events rather than raw touch handlers, you can sometimes lean on the built-in locator.dragTo() or a page.mouse.down() / page.mouse.move() / page.mouse.up() sequence with multiple steps. But mouse-based drags will not fire touchstart/touchmove, so prefer the touch helper whenever the UI was built for fingers.

Pinch and Multi-Touch via CDP

Pinch-to-zoom needs two simultaneous touch points moving apart (zoom in) or together (zoom out). The standard page.touchscreen API models a single point, so true multi-touch requires either dispatching custom TouchEvent objects with two entries in the touches array or driving the Chrome DevTools Protocol directly. In Chromium you can open a CDP session and call Input.dispatchTouchEvent with two touch points per frame.

import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['Pixel 7'] });

test('pinch to zoom out a map (Chromium CDP)', async ({ page }) => {
  await page.goto('https://your-app.example/map');

  const map = page.getByTestId('map');
  const box = await map.boundingBox();
  if (!box) throw new Error('map not found');

  const cx = box.x + box.width / 2;
  const cy = box.y + box.height / 2;
  const session = await page.context().newCDPSession(page);

  const points = (gap: number) => [
    { x: cx - gap, y: cy },
    { x: cx + gap, y: cy },
  ];

  // Two fingers down, far apart
  await session.send('Input.dispatchTouchEvent', {
    type: 'touchStart',
    touchPoints: points(120),
  });

  // Move them together over several frames => pinch in (zoom out)
  for (let gap = 110; gap >= 20; gap -= 15) {
    await session.send('Input.dispatchTouchEvent', {
      type: 'touchMove',
      touchPoints: points(gap),
    });
    await page.waitForTimeout(16);
  }

  await session.send('Input.dispatchTouchEvent', {
    type: 'touchEnd',
    touchPoints: [],
  });

  await expect(map).toHaveAttribute('data-zoom', '1');
});

To pinch out (zoom in), simply reverse the loop so the gap grows from small to large. The newCDPSession approach is Chromium-only; WebKit and Firefox do not expose CDP. For cross-browser multi-touch you must fall back to manually constructed TouchEvents in page.evaluate, building a touches array with two Touch instances that share a common target but carry different identifier values.

Gesture Method Comparison

The table below summarizes which API to reach for, what events it produces, and which browsers support it. Use it to decide between high-level convenience methods and low-level control.

GestureRecommended APIEvents firedBrowser support
Taplocator.tap()touchstart, touchend, pointer*, clickAll (needs hasTouch)
Tap at coordinatespage.touchscreen.tap(x, y)touchstart, touchendAll (needs hasTouch)
Swipe / dragCustom helper via evaluatetouchstart, touchmove, touchendAll
Single-pointer draglocator.dragTo() / mousepointer* / mouse* (no touch)All
Pinch (2 fingers)CDP Input.dispatchTouchEventtouchstart/move/end (multi)Chromium only
Pinch cross-browserManual TouchEvent in evaluatetouchstart/move/end (multi)All

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Making Gesture Tests Stable

Touch tests are notoriously flaky because they depend on timing, animation, and physics-based momentum scrolling. A few habits keep them green:

  • Always derive coordinates from a live boundingBox() rather than hard-coding pixels, so a viewport change does not silently move your gesture off target.
  • Emit enough touchmove steps. Too few and the gesture recognizer treats it as a tap; ten to fifteen intermediate points at a 16ms cadence mimics a real 60fps finger drag.
  • Disable CSS animations and momentum where you can. Inject a no-transition style via page.addStyleTag() so the assertion does not race a settling animation.
  • Assert on a stable post-gesture state (a visible slide, a changed data-zoom attribute, a fired analytics event) instead of pixel positions. Use web-first assertions like expect(locator).toBeVisible() that auto-retry.
  • Add expect.soft() for non-blocking checks when you want to capture multiple gesture outcomes in one test without bailing on the first mismatch.
// Kill animations so assertions don't race a settling transition.
await page.addStyleTag({
  content:
    '*, *::before, *::after { animation: none !important; transition: none !important; }',
});

Conclusion

Mastering Playwright touch gestures swipe tap and pinch comes down to two disciplines: run inside a context with hasTouch enabled so the real touch code path fires, and drive interactions through the Touchscreen API or CDP rather than the mouse. Use locator.tap() for everyday taps, a small interpolated-touchmove helper for swipes and drags, and Input.dispatchTouchEvent over a CDP session for true multi-finger pinch. Combine those with bounding-box-relative coordinates, disabled animations, and web-first assertions, and your mobile gesture suite will stay both realistic and reliably green.

FAQ

Why does locator.tap() throw “The page does not support tap”?

That error means the browser context was created without touch support. The tap() action only works when the context has hasTouch: true, which you get by spreading a mobile device descriptor like devices['Pixel 7'] or by passing hasTouch: true to browser.newContext(). Add device emulation to your project in playwright.config.ts and the error disappears.

Can Playwright do a real two-finger pinch on all browsers?

True multi-touch pinch using the CDP Input.dispatchTouchEvent method works only in Chromium, because WebKit and Firefox do not expose the Chrome DevTools Protocol. For cross-browser pinch you build the touch events yourself inside page.evaluate, dispatching TouchEvent objects whose touches array contains two Touch instances with distinct identifier values that move apart or together.

Why does my swipe register as a tap instead of moving the carousel?

Gesture recognizers measure distance and velocity. If you jump straight from the start point to the end point with no intermediate touchmove events, the page sees a press and release in roughly the same spot and treats it as a tap. Emit ten to fifteen interpolated touchmove events at about a 16ms cadence between touchstart and touchend so the recognizer reads a genuine directional drag.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.