Skip to content

Instantly share code, notes, and snippets.

@mutewinter
Last active December 8, 2021 11:01
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mutewinter/b86fc06bae43fd3e13169bfb569b06b1 to your computer and use it in GitHub Desktop.
Save mutewinter/b86fc06bae43fd3e13169bfb569b06b1 to your computer and use it in GitHub Desktop.
Prevent Double Tap Zoom in React for Rapidly Tapped Buttons

Prevent Double Tap Zoom in React for Rapidly Tapped Buttons

Touch delay is a thing of the past, but accidental zooming is here to ruin your day. Ever tapped a button quickly on iOS and experienced a zoom instead of two taps? You're in the right place.

Before

Before

After

After

Code

Just use this helper function to preventDefault on double taps, while still allowing for pinch to zoom and rapid scrolling.

// Ensure touches occur rapidly
const delay = 500
// Sequential touches must be in close vicinity
const minZoomTouchDelta = 10

// Track state of the last touch
let lastTapAt = 0
let lastClientX = 0
let lastClientY = 0

export default function preventDoubleTapZoom(event) {
  // Exit early if this involves more than one finger (e.g. pinch to zoom)
  if (event.touches.length > 1) {
    return
  }

  const tapAt = new Date().getTime()
  const timeDiff = tapAt - lastTapAt
  const { clientX, clientY } = event.touches[0]
  const xDiff = Math.abs(lastClientX - clientX)
  const yDiff = Math.abs(lastClientY - clientY)
  if (
    xDiff < minZoomTouchDelta &&
    yDiff < minZoomTouchDelta &&
    event.touches.length === 1 &&
    timeDiff < delay
  ) {
    event.preventDefault()
    // Trigger a fake click for the tap we just prevented
    event.target.click()
  }
  lastClientX = clientX
  lastClientY = clientY
  lastTapAt = tapAt
}

Example Use

const TapAsFastAsYouWantButton = (children, ...rest) =>
  <button {...props} onTouchStart={preventDoubleTapZoom}>
    {children}
  </button>

const Game = () =>
  <TapAsFastAsYouWantButton onClick={fireLasers}>
    Fire Lasers
  </TapAsFastAsYouWantButton>
@kachkaev
Copy link

kachkaev commented Oct 7, 2017

Hi @mutewinter! Great to see a potential fix to a problem that bothers me too! Do you think your solution can be used as a global preventor of all double clicks for all components? Similar to how react-fastclick works.

UPD: Own solution: https://stackoverflow.com/a/46624015/1818285

@jonathanmv
Copy link

My double tap to zoom was still happening after using this function and a warning was shown in the console

Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/features/5093566007214080

Based on this answer I set the touch-action: none; in the element's css and it worked for me. I haven't tested it thoroughly though.

@thaytharma
Copy link

Hey, do you have anything similar working for Vue.Js 2?

@heyqbnk
Copy link

heyqbnk commented Apr 21, 2020

Worked for me with some remaks. It is not allowed to add event listener on React's onTouchStart due to it adds passive event listener. You have to use refs for target container and add event listener like rootRef.current.addEventListener('touchstart', onTouchStart, {passive: false}).

The other way you can make it easier is to add event listener to document.body.

Leaving React Hooks Typescript alternative:

import {useCallback, useRef} from 'react';

type OnTouchStart = (event: TouchEvent) => void;

interface Meta {
  lastTapAt: number;
  lastClientX: number;
  lastClientY: number;
}

/**
 * Returns handler which prevents from scrolling while double tapping
 * screen
 */
export function useDoubleTap(): OnTouchStart {
  // Ensure touches occur rapidly
  const delay = 1000;
  // Sequential touches must be in close vicinity
  const minZoomTouchDelta = 10;

  // Track state of the last touch
  const meta = useRef<Meta>({
    lastTapAt: 0,
    lastClientX: 0,
    lastClientY: 0,
  });

  return useCallback<OnTouchStart>(event => {
    // Exit early if this involves more than one finger (e.g. pinch to zoom)
    if (event.touches.length > 1) {
      return;
    }

    const tapAt = new Date().getTime();
    const timeDiff = tapAt - meta.current.lastTapAt;
    const {clientX, clientY} = event.touches[0];
    const xDiff = Math.abs(meta.current.lastClientX - clientX);
    const yDiff = Math.abs(meta.current.lastClientY - clientY);
    if (
      xDiff < minZoomTouchDelta &&
      yDiff < minZoomTouchDelta &&
      event.touches.length === 1 &&
      timeDiff < delay
    ) {
      event.preventDefault();

      // Trigger a fake click for the tap we just prevented
      if (event.target) {
        event.target.dispatchEvent(new Event('click', {
          bubbles: true,
          cancelable: true,
        }));
      }
    }
    meta.current.lastClientX = clientX;
    meta.current.lastClientY = clientY;
    meta.current.lastTapAt = tapAt;
  }, []);
}

@Coridyn
Copy link

Coridyn commented Apr 24, 2020

There is a pure CSS fix that may work also: touch-action: manipulation;

The MDN page on touch-action describes it:

"[manipulation] disable[s] additional non-standard gestures such as double-tap to zoom. Disabling double-tap to zoom removes the need for browsers to delay the generation of click events when the user taps the screen."

It's also well supported; available since iOS 9.3 and Android Chrome 36.

@pspEgg
Copy link

pspEgg commented Jul 11, 2020

There is a pure CSS fix that may work also: touch-action: manipulation;

The MDN page on touch-action describes it:

"[manipulation] disable[s] additional non-standard gestures such as double-tap to zoom. Disabling double-tap to zoom removes the need for browsers to delay the generation of click events when the user taps the screen."

It's also well supported; available since iOS 9.3 and Android Chrome 36.

This answer worked for me!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment