# Back Every Gesture With a Simple Control

**MUST** · **ID:** `interactions-gesture-alternative` · **Category:** interactions
**Source:** [WCAG](https://www.w3.org/WAI/WCAG21/quickref/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-gesture-alternative

> MUST: Any multipoint or path-based gesture (swipe, pinch, two-finger rotate, traced path) needs a discrete single-pointer control beside it — Prev/Next buttons, real pagination dot buttons, +/− zoom, arrow keys on the focused region. WCAG SC 2.5.1 Pointer Gestures, Level A. A drawing canvas is essential; a carousel never is.

Pair swipes, pinches and traced paths with buttons or keys that need no path at all

The sibling of "Never Make Dragging the Only Path" (SC 2.5.7), and the two are constantly confused. 2.5.7 is about dragging — press, travel, release, where the travel is what moves the thing. This one, 2.5.1, is about gestures whose SHAPE or number of contact points carries the meaning: a swipe, a pinch-zoom, a two-finger rotate, a drawn checkmark, a slider you can only operate by tracing. It is Level A, one step stricter, because a swipe-only carousel does not merely inconvenience someone — it walls off the content entirely. The remedy is the same shape as 2.5.7's: keep the gesture as an accelerator for the people who like it, and put a discrete single-pointer control next to it. Prev / Next buttons, pagination dots that are real buttons, +/− zoom controls, arrow-key handling on the focused region. "Essential" is narrow: a drawing canvas or a map that genuinely requires a free-form path qualifies; a carousel never does.

## Bad — do not do this

`interactions-gesture-alternative-bad`

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

const SLIDES = ['Overview', 'Pricing', 'Changelog', 'Support'];

export function GestureAlternativeBad() {
  const [index, setIndex] = useState(0);
  const startX = useRef<number | null>(null);

  return (
    <div className="w-full max-w-sm">
      {/* The ONLY way to change slides is a path-based gesture: press, travel at
          least 40px horizontally, release. No buttons, no keyboard, nothing
          focusable. A tap does nothing. */}
      <div
        onPointerDown={(e) => {
          startX.current = e.clientX;
        }}
        onPointerUp={(e) => {
          if (startX.current === null) return;
          const dx = e.clientX - startX.current;
          startX.current = null;
          if (Math.abs(dx) < 40) return; // a tap is not a swipe
          setIndex((i) =>
            dx < 0 ? Math.min(i + 1, SLIDES.length - 1) : Math.max(i - 1, 0)
          );
        }}
        className="flex h-32 touch-pan-y select-none items-center justify-center rounded-lg border border-border bg-card text-lg font-medium text-foreground"
      >
        {SLIDES[index]}
      </div>

      <div className="mt-3 flex justify-center gap-1.5">
        {SLIDES.map((slide, i) => (
          <span
            key={slide}
            aria-hidden="true"
            className={`size-2 rounded-full ${i === index ? 'bg-primary' : 'bg-muted'}`}
          />
        ))}
      </div>

      <p className="mt-3 text-xs text-muted-foreground">
        Press inside the card and drag sideways — it advances. Now try anything else:
        a single click does nothing, Tab never reaches the carousel, arrow keys do
        nothing. The dots are decoration, not controls. The only route through this
        content is a horizontal path traced with a pointer.
      </p>

      <p className="mt-2 text-xs text-destructive">
        Path-based gesture is the sole operation — WCAG 2.5.1 failure
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-gesture-alternative-good`

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

const SLIDES = ['Overview', 'Pricing', 'Changelog', 'Support'];

export function GestureAlternativeGood() {
  const [index, setIndex] = useState(0);
  const startX = useRef<number | null>(null);

  const go = (next: number) =>
    setIndex(Math.min(Math.max(next, 0), SLIDES.length - 1));

  return (
    <div className="w-full max-w-sm">
      {/* Swipe is kept — it is just no longer load-bearing. The same region is a
          single tab stop that answers ArrowLeft / ArrowRight. */}
      <div
        role="group"
        aria-label="Slides"
        tabIndex={0}
        onPointerDown={(e) => {
          startX.current = e.clientX;
        }}
        onPointerUp={(e) => {
          if (startX.current === null) return;
          const dx = e.clientX - startX.current;
          startX.current = null;
          if (Math.abs(dx) < 40) return;
          go(index + (dx < 0 ? 1 : -1));
        }}
        onKeyDown={(e) => {
          if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
          e.preventDefault();
          e.stopPropagation();
          go(index + (e.key === 'ArrowRight' ? 1 : -1));
        }}
        className="flex h-32 touch-pan-y select-none items-center justify-center rounded-lg border border-border bg-card text-lg font-medium text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        {SLIDES[index]}
      </div>

      <div className="mt-3 flex items-center justify-center gap-3">
        {/* Single-pointer alternative: a discrete tap on a discrete target. */}
        <button
          type="button"
          onClick={() => go(index - 1)}
          disabled={index === 0}
          className="flex size-8 items-center justify-center rounded border border-border text-foreground hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
        >
          <span aria-hidden="true">←</span>
          <span className="sr-only">Previous slide</span>
        </button>

        <div className="flex gap-1.5">
          {SLIDES.map((slide, i) => (
            <button
              key={slide}
              type="button"
              onClick={() => go(i)}
              aria-label={`Go to ${slide}`}
              aria-current={i === index ? 'true' : undefined}
              className="flex size-6 items-center justify-center rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              <span
                aria-hidden="true"
                className={`size-2 rounded-full ${i === index ? 'bg-primary' : 'bg-muted'}`}
              />
            </button>
          ))}
        </div>

        <button
          type="button"
          onClick={() => go(index + 1)}
          disabled={index === SLIDES.length - 1}
          className="flex size-8 items-center justify-center rounded border border-border text-foreground hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-30"
        >
          <span aria-hidden="true">→</span>
          <span className="sr-only">Next slide</span>
        </button>
      </div>

      <p className="mt-3 text-xs text-muted-foreground">
        Swipe still works. So does clicking ← / → , clicking a dot (each is a real
        button with a ≥24px hit target), and pressing the arrow keys once the card
        itself is focused. Every operation is reachable with one pointer, no path.
      </p>

      <p className="mt-2 text-xs text-success">
        The gesture is an accelerator layered over single-pointer controls
      </p>
    </div>
  );
}
```

## References

- [WCAG 2.1: Understanding SC 2.5.1 Pointer Gestures](https://www.w3.org/WAI/WCAG21/Understanding/pointer-gestures.html)
