WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Component guides
  4. dnd kit Mobile: Touch Drag That Lets the Page Scroll
  1. Blog
  2. Component guides
  3. dnd kit Mobile: Touch Drag That Lets the Page Scroll
Component guides
Component guides

dnd kit Mobile: Touch Drag That Lets the Page Scroll

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 11 min read

On this page

0%
  1. Why is dnd kit not working on mobile?
  2. Which dnd kit sensors work on a touch screen?
  3. What delay and tolerance should the TouchSensor use?
  4. Where does touch-action go?
  5. Why does a drag cancel on iPhone when the page scrolls?
  6. How do you make drag and drop accessible?
  7. How do you build a sortable list that works on phones?
  8. Is there a React kanban board that already works on phones?
  9. FAQ
    1. Why is dnd kit not working on mobile?
    2. Can I use PointerSensor and TouchSensor together in dnd kit?
    3. What delay and tolerance should the dnd kit TouchSensor use?
    4. Why does dnd kit cancel my drag on iPhone?
    5. Is keyboard drag and drop enough for accessibility?
    6. Should I switch to @dnd-kit/react for mobile 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.

Published Oct 9, 2026

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, which the Deal Pipeline builds on; the 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 (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab), "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.

Sensorstouch-actionSwipe at onceHold 400ms, then move
PointerSensor + TouchSensormanipulationdrag starts, then cancels as the page scrollsthe same: no long press ever lifts it
PointerSensor + TouchSensornonedrag starts after 8px, the page cannot scrolldrag starts after 8px
pointer sensor that skips touch + TouchSensormanipulationthe page scrolls, no dragdrag 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:

SettingToo lowToo high
delaya slow swipe lifts a card instead of scrollingthe drag feels broken, and it collides with the system long press (text selection, the iOS callout)
tolerancea resting finger drifts a pixel and the drag never startsa slow scroll can turn into a drag halfway through

Never use a tolerance of zero. The Touch sensor docs (opens in a new tab) say that with zero, any movement during the delay aborts the drag, and a finger resting on glass always moves a little. Issue #1955 (opens in a new tab), 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 (opens in a new tab) 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 (opens in a new tab), checked in October 2026; the web vibration API guide 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 surfaceSensor that owns the fingertouch-actionTailwind v4
The whole card or row, long pressTouchSensormanipulationtouch-manipulation
A grip buttoneithernonetouch-none
The scroll containerno sensorleave it autonothing

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), 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 (opens in a new tab) 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), 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.

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.
$ npx wingo-ui@latest add kanban
ProKanban docs

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 (opens in a new tab) (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 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.

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.
$ npx wingo-ui@latest add list
ProList docs

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). For the other phone gestures, such as swipe actions and drag to dismiss, read the mobile-first React components guide. The 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.

Components in this post

  • Kanban

    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.

    Pro
  • List

    Rows on a page for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder.

    Pro
  • Deal Pipeline

    The sales pipeline: stages with weighted totals, a forecast summary, drag between stages with a lost reason, and a list view.

    Pro

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.

  • dnd kit
  • Drag and drop
  • Mobile UI
  • Accessibility
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

React Pull to Refresh in a PWA Without Fighting the Browser

Build a React pull to refresh for a PWA: overscroll-behavior, rubberband resistance, iOS spokes or a Material ring, a haptic tick and a refresh button.

SRSerban Rusu·Oct 9, 2026·11 min read
Component guides

How to Update shadcn Components Without Losing Edits

Update shadcn components without losing edits: what shadcn diff and add --diff show, why a 2-way diff hides whose change is whose, and how a 3-way merge works.

SRSerban Rusu·Oct 9, 2026·10 min read
Component guides

IBAN Validation JavaScript: Mod 97, Regex and a React Field

IBAN validation JavaScript that catches typos: mod 97 in TypeScript, why a regex is not enough, a React field that formats as you type and Romanian banks.

SRSerban Rusu·Oct 9, 2026·12 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI