# dnd kit Mobile: Touch Drag That Lets the Page Scroll

> Fix dnd kit mobile drag: a TouchSensor long press that still lets the page scroll, touch-action per element, the iOS Safari resize cancel and a tap path.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag

## TL;DR

To make dnd kit mobile drag work without blocking the page scroll, replace the default sensors with a pointer sensor that ignores touch, a TouchSensor with a 250ms delay and 5 to 8px of tolerance, and a KeyboardSensor, then set touch-action: manipulation on long-press surfaces and touch-action: none only on drag handles. Keep the drag inside its own scroll container, because @dnd-kit/core cancels a drag on window resize and iOS Safari resizes the window when its toolbar collapses. Add a tap path, such as a Move to menu or up and down buttons, because keyboard drag alone does not meet WCAG 2.5.7.

You built a sortable list or a kanban board with dnd kit, it works with a mouse, and on a phone it falls apart: the page scrolls while the card stays put, or the card grabs every swipe and the list never scrolls. This tutorial fixes dnd kit mobile drag step by step: which sensors to register, the activation delay versus scroll trade-off, where `touch-action` goes, why iOS Safari cancels drags, and the keyboard and tap paths that make drag and drop accessible. The sensor setup is the one in the Wingo UI [Kanban](https://wingo-ui.com/components/kanban), which the [Deal Pipeline](https://wingo-ui.com/components/deal-pipeline) builds on; the [List](https://wingo-ui.com/components/list) shows the drag handle variant.

As of October 2026, the latest `@dnd-kit/core` is still 6.3.1 from December 2024, and the [legacy docs](https://dndkit.com/legacy/introduction/installation/) recommend the new version of dnd kit (`@dnd-kit/react` for React), which is at 0.5.0, before 1.0. This post targets `@dnd-kit/core` 6.3.1 and `@dnd-kit/sortable` 10.0.0.

## Why is dnd kit not working on mobile?

Because `DndContext` uses the PointerSensor and the KeyboardSensor by default, and pointer events cannot stop a scroll. When a finger moves on an element that allows panning, the browser starts scrolling and fires `pointercancel`, which ends the drag, and calling `preventDefault()` in `pointermove` changes nothing. The [dnd kit Pointer sensor docs](https://dndkit.com/legacy/api-documentation/sensors/pointer) say it plainly: "Using `touch-action: none;` is the only way to reliably prevent scrolling for pointer events."

That leaves two tools, each with a cost:

- **`touch-action: none` on the draggable.** The drag works, and a swipe that starts on that element can never scroll the page. Fine for a small grip, bad for a list of full-width rows: when every row is `none`, no swipe on the list scrolls it. The docs suggest `none` on a drag handle only, for this reason.
- **The TouchSensor.** Touch events can be canceled in `touchmove`, so a drag can wait for a long press and still let a swipe scroll. The same docs page recommends the Mouse and Touch sensors whenever `none` does not fit your layout.

The problem is old: [issue #435](https://github.com/clauderic/dnd-kit/issues/435), "Dragging with PointerSensor does not work well on touch devices", dates from August 2021. When the rows themselves must scroll, React drag and drop on touch screen devices needs the TouchSensor, next to a sensor for the mouse.

## Which dnd kit sensors work on a touch screen?

Three: a pointer sensor that ignores fingers, the dnd kit touch sensor with a delay, and the keyboard sensor. The trap is registering the stock PointerSensor next to the TouchSensor. A touch fires `pointerdown` before `touchstart`, and `DndContext` ignores a sensor's activator while another sensor is pending; the check in the 6.3.1 source is commented "Another sensor is already instantiating". So the PointerSensor takes every finger and the TouchSensor's delay never runs.

We tested it with `@dnd-kit/core` 6.3.1 in headless Chromium with emulated touch: one draggable on a page taller than the screen, an 8px distance on the pointer sensor, 250ms and 8px on the touch sensor, and a finger moving 60px either at once or after a 400ms hold.

| Sensors | `touch-action` | Swipe at once | Hold 400ms, then move |
| --- | --- | --- | --- |
| PointerSensor + TouchSensor | `manipulation` | drag starts, then cancels as the page scrolls | the same: no long press ever lifts it |
| PointerSensor + TouchSensor | `none` | drag starts after 8px, the page cannot scroll | drag starts after 8px |
| pointer sensor that skips touch + TouchSensor | `manipulation` | the page scrolls, no drag | drag starts at 250ms, the page stays put |

A common workaround picks one sensor by device type, which breaks on touch screen laptops, where one person uses both the trackpad and the screen. The last row is the cleaner fix: a PointerSensor subclass whose activator skips touch pointers, so the mouse and pen take the pointer path and fingers take the touch path. Our Kanban runs the same pointer and touch sensors. Save this as `components/use-drag-sensors.ts`; the full example at the end imports it:

```ts
"use client";

import { KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";

// PointerSensor for mouse and pen only: fingers are left to the TouchSensor
export class MousePointerSensor extends PointerSensor {
  static activators = [
    {
      eventName: "onPointerDown" as const,
      handler: ({ nativeEvent: event }: { nativeEvent: PointerEvent }) =>
        event.isPrimary && event.button === 0 && event.pointerType !== "touch",
    },
  ];
}

export function useDragSensors() {
  return useSensors(
    // mouse and pen: 8px of travel, so a click is still a click
    useSensor(MousePointerSensor, { activationConstraint: { distance: 8 } }),
    // touch: a 250ms press, so a swipe still scrolls
    useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
    // keyboard: Space or Enter lifts, arrows move, Space or Enter drops, Escape cancels
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );
}
```

The stock `MouseSensor` plus `TouchSensor` pairing from the docs works too.

## What delay and tolerance should the TouchSensor use?

250ms with 5 to 8px of tolerance. The delay is how long a finger must rest before the card lifts; the tolerance is how far it may drift in that time before dnd kit gives up and lets the browser scroll. Those two numbers are the whole dnd kit mobile scroll trade-off:

| Setting | Too low | Too high |
| --- | --- | --- |
| `delay` | a slow swipe lifts a card instead of scrolling | the drag feels broken, and it collides with the system long press (text selection, the iOS callout) |
| `tolerance` | a resting finger drifts a pixel and the drag never starts | a slow scroll can turn into a drag halfway through |

Never use a tolerance of zero. The [Touch sensor docs](https://dndkit.com/legacy/api-documentation/sensors/touch) say that with zero, any movement during the delay aborts the drag, and a finger resting on glass always moves a little. [Issue #1955](https://github.com/clauderic/dnd-kit/issues/1955), opened in March 2026 against the rewrite, reported drags that never started on some Android phones, mostly Samsung; in July 2026 the reporter traced it to a `tolerance: 0` and fixed it with 5. The rewrite's PointerSensor gives touch [a 250ms delay with 5px tolerance](https://dndkit.com/extend/sensors/pointer-sensor) by default; our Kanban and Deal Pipeline use 250ms and 8px.

A long press needs feedback at the moment it fires, or people let go too early. The Kanban calls `navigator.vibrate(10)` in `onDragStart` when the activator event is a `TouchEvent`, and lifts the card with a small scale and a 2 degree tilt on a spring. The tick does nothing on an iPhone, because Safari on iOS has no Vibration API ([caniuse](https://caniuse.com/vibration), checked in October 2026; the [web vibration API guide](https://wingo-ui.com/blog/web-vibration-api-haptic-feedback) covers what an iPhone can do instead).

## Where does touch-action go?

On the element that receives the listeners, with a value that depends on which sensor owns the finger. The docs recommend `manipulation` for the TouchSensor and `none` for the PointerSensor, and the browser reads the value when the touch starts: changing it after `pointerdown` or `touchstart` is ignored. Put it in the CSS, never in a drag start handler.

| Drag surface | Sensor that owns the finger | `touch-action` | Tailwind v4 |
| --- | --- | --- | --- |
| The whole card or row, long press | TouchSensor | `manipulation` | `touch-manipulation` |
| A grip button | either | `none` | `touch-none` |
| The scroll container | no sensor | leave it `auto` | nothing |

`manipulation` keeps panning and pinch zoom and drops double-tap zoom; once the drag is active, the TouchSensor cancels `touchmove` itself. A long-press surface needs two more classes: `select-none`, so the press does not select the card's text, and `[-webkit-touch-callout:none]`, so iOS does not open its callout. dnd kit already cancels `contextmenu` on the window while a touch is pending or dragging.

The Wingo UI List takes the grip route: a 32px button with `touch-none` whose hit area grows to 44px on coarse pointers ([why 44px](https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind)), so a swipe on the row still scrolls the page or opens its swipe actions.

## Why does a drag cancel on iPhone when the page scrolls?

Because `@dnd-kit/core` cancels any pointer or touch drag on a window `resize`, and Safari on iOS fires `resize` when its toolbar collapses or expands as the document scrolls. [Issue #686](https://github.com/clauderic/dnd-kit/issues/686) traced it on iOS 15 in March 2022. It was closed in February 2026 as filed against the old version, not fixed: the `resize` listener is still in the 6.3.1 sensors, so the cancel still happens.

The fix is layout: keep the drag inside its own scroll container so the document does not move. Give the board or list a real height (`h-dvh`, or `flex-1 min-h-0` in a full-height layout; see [100vh on mobile](https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind)), `overflow-y-auto` for a list or `overflow-x-auto` for a board, and `overscroll-contain` so reaching the end does not hand the scroll to the page.

The container also decides what auto-scroll moves. dnd kit scrolls the item's scrollable ancestors, meaning elements with `overflow` set to `auto` or `scroll`, plus the document, when the pointer comes within 20% of their edge. A wrapper with `overflow: hidden` does not count, so a board clipped that way cannot auto-scroll to its hidden columns; the page scrolls instead, if it can. If a phone board uses scroll snap, pause it during the drag: the Kanban board is `snap-x snap-mandatory` with `data-dragging:snap-none`, so the auto-scroll can glide to the next column.

Try it at phone width (or in device mode): the Kanban shows one column at a time with the next one peeking, a swipe scrolls it, and a 250ms press lifts a card you can carry to the edge to reach the next column.

> Live demo (Kanban): Long press a card to lift it, then carry it to the board's edge to reach the next column; a quick swipe still scrolls. With a mouse it lifts after 8px. Tab to a card and press Space to move it with the arrow keys. Try it at [Kanban](https://wingo-ui.com/components/kanban) and install it with `npx wingo-ui@latest add kanban`.

## How do you make drag and drop accessible?

With two separate paths, and dnd kit gives you one of them. The KeyboardSensor covers keyboard users: by default Space or Enter lifts, the arrow keys move, Space, Enter or Tab drops and Escape cancels, while `DndContext` reads instructions and live announcements to screen readers. Write your own `announcements`, because the defaults read raw ids ("Picked up draggable item d02."). The Kanban passes `keyboardCodes: { start: ["Space"], cancel: ["Escape"], end: ["Space", "Enter"] }`, since Enter opens a card, and its own `coordinateGetter` sends Left and Right to the nearest card in the next column that accepts it.

The second path is for people who can tap but not drag, because of a tremor, a trackball or a head pointer. WCAG 2.2 success criterion [2.5.7 Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) (level AA) requires that dragging "can be achieved by a single pointer without dragging", and the W3C's explanation says keyboard equivalence alone does not meet it. One of its examples is the pattern a list needs: after a tap on an item, controls next to it move it up or down. For a board, the same idea is a menu that names the target column. The Kanban puts Move to first in every card's ⋯ menu, which opens as a bottom sheet on phones.

## How do you build a sortable list that works on phones?

Combine everything above in one component: the `useDragSensors` hook, long-press rows, a list with its own scroller, named announcements and up and down buttons for the tap path. It also passes `id={useId()}` to `DndContext`. Without it, dnd kit numbers its `aria-describedby` ids with a module-level counter, the server and the browser can count differently, and React reports a hydration mismatch in Next.js.

Install the packages with `npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities lucide-react` and the free [Button](https://wingo-ui.com/components/button) with `npx wingo-ui@latest add button`, then save this as `components/sortable-tasks.tsx`, next to `use-drag-sensors.ts`:

```tsx
"use client";

import { useId, useState } from "react";
import { DndContext, closestCenter, type Announcements, type DragEndEvent, type UniqueIdentifier } from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDragSensors } from "./use-drag-sensors";

type Task = { id: string; title: string };

export function SortableTasks({ initial }: { initial: Task[] }) {
  const [tasks, setTasks] = useState(initial);
  const sensors = useDragSensors();
  // a stable id: dnd kit's own counter differs between the server and the browser
  const dndId = useId();

  const name = (id: UniqueIdentifier) => tasks.find((task) => task.id === id)?.title ?? "Task";
  const place = (id: UniqueIdentifier) => `position ${tasks.findIndex((task) => task.id === id) + 1} of ${tasks.length}`;
  const announcements: Announcements = {
    onDragStart: ({ active }) => `Picked up ${name(active.id)}.`,
    onDragOver: ({ active, over }) => (over ? `${name(active.id)} is at ${place(over.id)}.` : undefined),
    onDragEnd: ({ active, over }) => (over ? `Dropped ${name(active.id)} at ${place(over.id)}.` : `${name(active.id)} is back in place.`),
    onDragCancel: ({ active }) => `Canceled. ${name(active.id)} is back in place.`,
  };

  const move = (from: number, to: number) => {
    if (to < 0 || to >= tasks.length || from === to) return;
    setTasks((current) => arrayMove(current, from, to));
  };

  const onDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over) return;
    move(tasks.findIndex((task) => task.id === active.id), tasks.findIndex((task) => task.id === over.id));
  };

  return (
    <DndContext
      id={dndId}
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={onDragEnd}
      accessibility={{
        announcements,
        screenReaderInstructions: {
          draggable: "Press Space to pick up a task. Arrow keys move it, Space drops it, Escape cancels.",
        },
      }}
    >
      <SortableContext items={tasks} strategy={verticalListSortingStrategy}>
        {/* its own scroller: the drag scrolls this list, not the document, so Safari's toolbar stays put */}
        <ul className="flex max-h-[70dvh] flex-col gap-2 overflow-y-auto overscroll-contain p-1">
          {tasks.map((task, index) => (
            <TaskRow key={task.id} task={task} index={index} last={index === tasks.length - 1} onMove={move} />
          ))}
        </ul>
      </SortableContext>
    </DndContext>
  );
}

type TaskRowProps = { task: Task; index: number; last: boolean; onMove: (from: number, to: number) => void };

function TaskRow({ task, index, last, onMove }: TaskRowProps) {
  const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
    id: task.id,
  });

  return (
    <li
      ref={setNodeRef}
      style={{ transform: CSS.Transform.toString(transform), transition }}
      data-dragging={isDragging || undefined}
      className="flex items-center gap-1 rounded-2xl bg-card ps-4 pe-1 shadow-raised data-dragging:relative data-dragging:z-10 data-dragging:shadow-floating"
    >
      {/* the label is the drag surface: a swipe scrolls the list, a 250ms press lifts the row */}
      <div
        ref={setActivatorNodeRef}
        {...attributes}
        {...listeners}
        className="flex min-h-12 flex-1 cursor-grab touch-manipulation select-none items-center text-sm outline-hidden [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring"
      >
        {task.title}
      </div>
      {/* the same move with single taps (WCAG 2.5.7) */}
      <Button variant="ghost" size="icon" radius="full" aria-label={`Move ${task.title} up`} disabled={index === 0} onClick={() => onMove(index, index - 1)}>
        <ArrowUp />
      </Button>
      <Button variant="ghost" size="icon" radius="full" aria-label={`Move ${task.title} down`} disabled={last} onClick={() => onMove(index, index + 1)}>
        <ArrowDown />
      </Button>
    </li>
  );
}
```

A server page, such as `app/tasks/page.tsx`, can render it with plain data:

```tsx
import { SortableTasks } from "@/components/sortable-tasks";

const tasks = [
  { id: "t1", title: "Send the New York lease to Emma Carter" },
  { id: "t2", title: "Book the photographer for Friday" },
  { id: "t3", title: "Review the Q4 budget" },
];

export default function TasksPage() {
  return <SortableTasks initial={tasks} />;
}
```

Then test on a real phone: device mode does not show the Safari toolbar, a real finger's drift or the system long press. Try every path with a screen reader on, too.

If you would rather not own this code for a plain list, the List has sortable rows built in: turn on `sortable`, then drag a row by its grip, or Tab to a grip and use Space and the arrow keys; Alt with Up or Down moves a focused row one step. It has no tap path for reordering, so for 2.5.7 add Move up and Move down entries to each row's `actions` menu (its ⋯ button is always visible on touch) and reorder your `items` in their handlers.

> Live demo (List): Drag a row by its grip to reorder the inbox; a swipe on the row still scrolls. Tab to a grip and press Space, then the arrow keys, or press Alt with Up or Down on a focused row. Try it at [List](https://wingo-ui.com/components/list) and install it with `npx wingo-ui@latest add list`.

## Is there a React kanban board that already works on phones?

Yes: the Wingo UI Kanban ships the touch, mouse and keyboard setup from this post, the Move to menu and the phone layout. `npx wingo-ui@latest add kanban` copies it and everything it imports into your project and installs its npm packages. The Kanban, List and Deal Pipeline are Pro components ([pricing](https://wingo-ui.com/pricing)). For the other phone gestures, such as swipe actions and drag to dismiss, read the [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components). The [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) applies the same touch, keyboard and screen reader rules to every form control, and more tutorials like this one are in the [component guides category](https://wingo-ui.com/blog/category/components).

## Components in this post

- [Kanban](https://wingo-ui.com/components/kanban) (Pro): A board of columns and cards for pipelines, orders and tasks: drag with mouse, touch or keyboard, WIP limits, inline add and a phone layout. Install: `npx wingo-ui@latest add kanban`
- [List](https://wingo-ui.com/components/list) (Pro): Rows on a page for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder. Install: `npx wingo-ui@latest add list`
- [Deal Pipeline](https://wingo-ui.com/components/deal-pipeline) (Pro): The sales pipeline: stages with weighted totals, a forecast summary, drag between stages with a lost reason, and a list view. Install: `npx wingo-ui@latest add deal-pipeline`

## FAQ

### Why is dnd kit not working on mobile?

DndContext uses the PointerSensor by default, and pointer events cannot stop the browser from scrolling, so a touch drag is canceled as soon as the page starts to pan. Either set touch-action: none on a drag handle, or let a TouchSensor with a 250ms delay own the finger, next to a pointer sensor that skips touch, so a long press lifts the item and a swipe still scrolls.

### Can I use PointerSensor and TouchSensor together in dnd kit?

Not the stock PointerSensor. A touch fires pointerdown before touchstart, so the PointerSensor claims the finger and the TouchSensor's delay never runs; subclass PointerSensor with an activator that skips pointerType touch, or pair MouseSensor with TouchSensor.

### What delay and tolerance should the dnd kit TouchSensor use?

Start with a 250ms delay and 5 to 8px of tolerance; the dnd kit rewrite uses 250ms and 5px as its touch default. Never set the tolerance to 0, because the docs say any movement during the delay then aborts the drag, and a resting finger always drifts a little.

### Why does dnd kit cancel my drag on iPhone?

@dnd-kit/core cancels pointer and touch drags on a window resize, and Safari on iOS fires resize when its toolbar collapses or expands as the page scrolls. Keep the drag inside its own scroll container so the document never scrolls during a drag.

### Is keyboard drag and drop enough for accessibility?

It covers keyboard users but not WCAG 2.2 success criterion 2.5.7 Dragging Movements, which asks for a way to do the same thing with single taps or clicks. Add a Move to menu or up and down buttons next to the drag.

### Should I switch to @dnd-kit/react for mobile drag?

Not for touch alone. The legacy docs point to the new version, and its PointerSensor gives touch a 250ms delay with 5px tolerance by default, but as of October 2026 @dnd-kit/react is at 0.5.0, before 1.0, with a different API. On @dnd-kit/core 6.3.1, the sensor setup in this post fixes touch drag.

---

Source: https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag
