# Disable touch-action on Custom Gesture Surfaces

**MUST** · **ID:** `interactions-gesture-touch-action` · **Category:** interactions
**Source:** [Rauno](https://interfaces.rauno.me/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-touch-action

> MUST: Set `touch-action: none` on surfaces that implement their own pan/zoom (map, canvas, carousel, drag handle) or the browser claims the gesture and fires `pointercancel` mid-drag. Scope it to the gesture surface — never the page.

Set touch-action: none on custom pan and zoom surfaces so the browser cannot steal the gesture

Note this is the opposite intent to the `touch-action: manipulation` rule: that one keeps native handling and only removes double-tap zoom, to kill the 300ms tap delay. This rule is for surfaces that implement their own pan/zoom — a map, a canvas, a carousel, a drag handle. There, if the browser retains native panning it claims the gesture as soon as it decides the movement is a scroll, fires `pointercancel`, and your pointer stream simply stops mid-drag. `touch-action: none` opts that element out of native scrolling and zooming so every pointer event reaches your handler. Scope it to the gesture surface only — putting `none` on a whole page makes it impossible to scroll.

## Rule snippet

```tsx
.canvas { touch-action: none; }
```

## Bad — do not do this

`interactions-gesture-touch-action-bad`

```tsx
import { useRef, useState, useEffect } from 'react';

export function GestureTouchActionBad() {
  const surfaceRef = useRef<HTMLDivElement>(null);
  const dragRef = useRef<{ x: number; y: number } | null>(null);
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const [cancels, setCancels] = useState(0);
  const [computed, setComputed] = useState('');

  // Report what the browser actually resolved, rather than asserting it.
  useEffect(() => {
    if (surfaceRef.current) {
      setComputed(getComputedStyle(surfaceRef.current).touchAction);
    }
  }, []);

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <p className="text-xs text-muted-foreground mb-3">
          Drag to pan the grid. On touch, the scroll strip below claims the gesture first: the browser fires
          <code> pointercancel</code> and the pan dies mid-drag.
        </p>

        {/* A scrollable ancestor: exactly the situation where the browser competes for the gesture. */}
        <div className="overflow-x-auto overscroll-x-contain border border-border rounded-md">
          <div
            ref={surfaceRef}
            // No touch-action: the browser keeps native panning/zooming for itself.
            onPointerDown={(e) => {
              dragRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
            }}
            onPointerMove={(e) => {
              if (!dragRef.current) return;
              setPan({ x: e.clientX - dragRef.current.x, y: e.clientY - dragRef.current.y });
            }}
            onPointerUp={() => {
              dragRef.current = null;
            }}
            onPointerCancel={() => {
              // The browser took the gesture. Our drag is over whether we like it or not.
              dragRef.current = null;
              setCancels((c) => c + 1);
            }}
            className="relative h-40 w-[520px] cursor-grab select-none bg-muted"
          >
            <div
              className="absolute inset-0"
              style={{
                transform: `translate3d(${pan.x}px, ${pan.y}px, 0)`,
                backgroundImage:
                  'linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)',
                backgroundSize: '24px 24px',
                opacity: 0.25,
              }}
            />
            <span className="absolute left-3 top-3 text-xs text-muted-foreground">Pan me</span>
          </div>
        </div>

        <dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
          <div>
            <dt className="text-muted-foreground">computed touch-action</dt>
            <dd className="font-mono text-foreground">{computed || '—'}</dd>
          </div>
          <div>
            <dt className="text-muted-foreground">gestures stolen</dt>
            <dd className="font-mono text-error">{cancels}</dd>
          </div>
        </dl>
      </div>
      <p className="text-xs text-error mt-4">
        With <code>touch-action: auto</code> the browser cancels the pointer stream and scrolls instead
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-gesture-touch-action-good`

```tsx
import { useRef, useState, useEffect } from 'react';

export function GestureTouchActionGood() {
  const surfaceRef = useRef<HTMLDivElement>(null);
  const dragRef = useRef<{ x: number; y: number } | null>(null);
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const [cancels, setCancels] = useState(0);
  const [computed, setComputed] = useState('');

  useEffect(() => {
    if (surfaceRef.current) {
      setComputed(getComputedStyle(surfaceRef.current).touchAction);
    }
  }, []);

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <p className="text-xs text-muted-foreground mb-3">
          Drag to pan the grid. <code>touch-action: none</code> opts the surface out of native panning and zooming,
          so every pointer stream reaches our handler intact.
        </p>

        <div className="overflow-x-auto overscroll-x-contain border border-border rounded-md">
          <div
            ref={surfaceRef}
            // touch-action: none — the browser hands the whole gesture to us and never cancels it.
            style={{ touchAction: 'none' }}
            onPointerDown={(e) => {
              e.currentTarget.setPointerCapture(e.pointerId);
              dragRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
            }}
            onPointerMove={(e) => {
              if (!dragRef.current) return;
              setPan({ x: e.clientX - dragRef.current.x, y: e.clientY - dragRef.current.y });
            }}
            onPointerUp={() => {
              dragRef.current = null;
            }}
            onPointerCancel={() => {
              dragRef.current = null;
              setCancels((c) => c + 1);
            }}
            className="relative h-40 w-[520px] cursor-grab select-none bg-muted"
          >
            <div
              className="absolute inset-0"
              style={{
                transform: `translate3d(${pan.x}px, ${pan.y}px, 0)`,
                backgroundImage:
                  'linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)',
                backgroundSize: '24px 24px',
                opacity: 0.25,
              }}
            />
            <span className="absolute left-3 top-3 text-xs text-muted-foreground">Pan me</span>
          </div>
        </div>

        <dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
          <div>
            <dt className="text-muted-foreground">computed touch-action</dt>
            <dd className="font-mono text-foreground">{computed || '—'}</dd>
          </div>
          <div>
            <dt className="text-muted-foreground">gestures stolen</dt>
            <dd className="font-mono text-success">{cancels}</dd>
          </div>
        </dl>
      </div>
      <p className="text-xs text-success mt-4">
        <code>touch-action: none</code> keeps the gesture ours — the counter stays at 0 on touch and pointer alike
      </p>
    </div>
  );
}
```

## References

- [interfaces.rauno.me](https://interfaces.rauno.me/)
- [MDN: touch-action](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action)
