# Never Make Dragging the Only Path

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

> MUST: Every drag must have a single-pointer alternative that reaches the same outcome without travel — ↑/↓ buttons on each row, a "Move to…" menu, or arrow-key handling on a focused grip. WCAG SC 2.5.7 Dragging Movements (AA); keep the drag as an accelerator. Only genuinely essential drags (signature pad, free-form canvas, map pan) are exempt.

Offer a single-pointer alternative to every drag: move buttons, arrow keys, or a menu

This is not a rule about how a drag feels — it is a rule about whether the outcome is reachable at all without one. Two neighbouring principles here already govern drag quality: "Clean Drag Interactions" suppresses text selection and inert-ifies the page during a drag, and "Drag Physics" tunes velocity and damping on release. Both assume the drag is happening and make it better. Neither asks the question this SC asks, which is what a user with a tremor, an eye-tracker, a head-pointer or a switch does when press-travel-release is the only route to the outcome — for them, the answer today is "nothing". So keep the drag; it is a fine accelerator. Then add a path that a single pointer can complete without travelling: ↑ / ↓ buttons on each row, a "Move to…" menu, or arrow-key handling on a focused grip. The alternative must produce the same result, not a degraded one, and only "essential" drags — a signature pad, a free-form canvas, a map pan — are exempt, because there the path IS the content.

## Bad — do not do this

`interactions-drag-alternative-bad`

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

const INITIAL = ['Design review', 'Ship changelog', 'Fix flaky test', 'Update docs'];

export function DragAlternativeBad() {
  const [items, setItems] = useState(INITIAL);
  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);

  const move = (from: number, to: number) => {
    setItems((prev) => {
      const next = [...prev];
      const [row] = next.splice(from, 1);
      next.splice(to, 0, row);
      return next;
    });
  };

  return (
    <div className="w-full max-w-sm">
      <ul className="space-y-2 rounded-lg border border-border bg-card p-3">
        {items.map((item, index) => (
          <li
            key={item}
            draggable
            onDragStart={() => setDraggedIndex(index)}
            onDragEnd={() => setDraggedIndex(null)}
            onDragOver={(e) => {
              e.preventDefault();
              if (draggedIndex !== null && draggedIndex !== index) {
                move(draggedIndex, index);
                setDraggedIndex(index);
              }
            }}
            className={`flex cursor-grab items-center gap-3 rounded border border-border bg-muted px-3 py-2 ${
              draggedIndex === index ? 'opacity-50' : ''
            }`}
          >
            {/* The grip is the ONLY affordance. It is a <span>: not focusable,
                no keyboard handler, no click alternative. Reordering exists
                exclusively as a dragging movement. */}
            <span aria-hidden="true" className="select-none text-muted-foreground">
              ⠿
            </span>
            <span className="text-sm text-foreground">{item}</span>
          </li>
        ))}
      </ul>

      <p className="mt-3 text-xs text-muted-foreground">
        Drag a row with the mouse — it reorders. Now try to reorder it any other way:
        press Tab (nothing in the list is focusable), or try a single tap. There is no
        path. A user with a tremor, an eye-tracker, or a head-pointer cannot complete
        this task at all.
      </p>

      <p className="mt-2 text-xs text-destructive">
        Reordering is drag-only, and reordering is not essential — WCAG 2.5.7 failure
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-drag-alternative-good`

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

const INITIAL = ['Design review', 'Ship changelog', 'Fix flaky test', 'Update docs'];

export function DragAlternativeGood() {
  const [items, setItems] = useState(INITIAL);
  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
  const gripRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const move = (from: number, to: number) => {
    if (to < 0 || to >= items.length) return;
    setItems((prev) => {
      const next = [...prev];
      const [row] = next.splice(from, 1);
      next.splice(to, 0, row);
      return next;
    });
  };

  // Single-pointer path (buttons) and keyboard path (arrows) both land here.
  const moveAndKeepFocus = (from: number, to: number) => {
    if (to < 0 || to >= items.length) return;
    move(from, to);
    requestAnimationFrame(() => gripRefs.current[to]?.focus());
  };

  return (
    <div className="w-full max-w-sm">
      <ul className="space-y-2 rounded-lg border border-border bg-card p-3">
        {items.map((item, index) => (
          <li
            key={item}
            draggable
            onDragStart={() => setDraggedIndex(index)}
            onDragEnd={() => setDraggedIndex(null)}
            onDragOver={(e) => {
              e.preventDefault();
              if (draggedIndex !== null && draggedIndex !== index) {
                move(draggedIndex, index);
                setDraggedIndex(index);
              }
            }}
            className={`flex items-center gap-2 rounded border border-border bg-muted px-2 py-1.5 ${
              draggedIndex === index ? 'opacity-50' : ''
            }`}
          >
            {/* Dragging still works. It is now one of three paths, not the only one. */}
            <button
              type="button"
              ref={(el) => {
                gripRefs.current[index] = el;
              }}
              aria-label={`Reorder ${item}. Use arrow up and arrow down keys.`}
              onKeyDown={(e) => {
                if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
                e.preventDefault();
                e.stopPropagation();
                moveAndKeepFocus(index, index + (e.key === 'ArrowDown' ? 1 : -1));
              }}
              className="flex size-6 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              <span aria-hidden="true">⠿</span>
            </button>

            <span className="flex-1 text-sm text-foreground">{item}</span>

            {/* Single-pointer alternative: one tap, no path, no dragging. */}
            <button
              type="button"
              aria-label={`Move ${item} up`}
              disabled={index === 0}
              onClick={() => moveAndKeepFocus(index, index - 1)}
              className="flex size-6 items-center justify-center rounded 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>
            </button>
            <button
              type="button"
              aria-label={`Move ${item} down`}
              disabled={index === items.length - 1}
              onClick={() => moveAndKeepFocus(index, index + 1)}
              className="flex size-6 items-center justify-center rounded 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>
            </button>
          </li>
        ))}
      </ul>

      <p className="mt-3 text-xs text-muted-foreground">
        Three ways to reorder, all real: drag a row with the mouse; tap ↑ / ↓ (a single
        pointer down-up on one target, no path); or focus a grip with Tab and press the
        arrow keys. Focus follows the row it moved, so you can keep going.
      </p>

      <p className="mt-2 text-xs text-success">
        Drag is retained as an accelerator, but never the only path to the outcome
      </p>
    </div>
  );
}
```

## References

- [WCAG 2.2: Understanding SC 2.5.7 Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html)
