[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-pointer-events-unify-mouse-touch":44,"search-suggestions":60,"quiz-article-pointer-events-unify-mouse-touch":108,"path-membership-pointer-events-unify-mouse-touch":301},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a152c8-5a34-7163-94b5-d9fcd26d2f4a","pointer-events-unify-mouse-touch","PRACTICE_QUIZ","Pointer Events: one model for mouse, touch, and pen","Check what stuck from the post: why mousedown\u002Ftouchstart duplication breaks, what setPointerCapture() actually fixes, and the cleanup event most drag handlers forget.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,100,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",161,{"slug":66,"name":67,"articles":68},"javascript","Javascript",130,{"slug":70,"name":71,"articles":72},"frontend","Frontend",92,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",63,{"slug":78,"name":79,"articles":80},"css","Css",49,{"slug":82,"name":83,"articles":84},"performance","Performance",27,{"slug":86,"name":87,"articles":88},"typescript","Typescript",23,{"slug":90,"name":91,"articles":92},"react","React",19,{"slug":94,"name":95,"articles":96},"programming","Programming",16,{"slug":98,"name":99,"articles":96},"browser","Browser",{"slug":101,"name":102,"articles":103},"node","Node",14,{"slug":105,"name":106,"articles":107},"html","Html",11,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":113,"publishedAt":114,"viewCount":115,"likeCount":19,"commentCount":19,"author":116,"vertical":121,"topic":122,"tags":124,"_count":129,"playground":131,"body":133,"bodyMd":287,"seo":288,"translationGroupId":291,"series":52,"podcastUrl":52,"verticalId":5,"thread":292,"assessments":294,"translations":297,"quiz":299},"01a152c8-59ef-7724-82e4-c6dc7fb6855f","You're Writing Mouse and Touch Handlers. Pointer Events Does Both.","A draggable card that works with mousedown\u002Fmousemove on desktop and breaks the moment you test it on a phone. The fix isn't a second set of touch listeners — it's deleting the mouse ones and switching to Pointer Events, which was built to handle mouse, touch, and pen as one input.","\u002Fmedia\u002Fcovers\u002Fpointer-events-unify-mouse-touch.png",6,"2026-11-01T14:50:55.724Z",35,{"id":117,"name":118,"username":119,"avatarUrl":52,"headline":120},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":123},"JavaScript",[125,126,127,128],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":98,"name":99,"color":52},{"slug":70,"name":71,"color":52},{"assessments":130},1,{"slug":46,"title":132},"Pointer Events — drag without losing the pointer",{"blocks":134,"version":130},[135,139,142,148,151,156,159,163,166,169,173,176,179,182,185,188,191,194,197,200,203,207,210,217,220,224,227,230,236,239,242,245,248,251,254,257,260,263,269,272,275,278,281],{"id":136,"html":137,"type":138},"b1","\u003Cp>The demo went great. Drag the card, it follows your cursor, drop it anywhere — smooth, on every laptop in the room. Then someone picks up their phone, drags a thumb across the card — and nothing happens. No movement, no feedback, like the card isn&#39;t even listening.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"type":138},"b2","\u003Cp>You didn&#39;t write a buggy drag handler. You wrote \u003Cstrong>half\u003C\u002Fstrong> of one.\u003C\u002Fp>",{"id":143,"code":144,"type":145,"language":146,"highlight":147},"b3","box.addEventListener('mousedown', (e) => {\n  dragging = true;\n  offsetX = e.clientX - box.offsetLeft;\n  offsetY = e.clientY - box.offsetTop;\n});\n\nbox.addEventListener('mousemove', (e) => {\n  if (!dragging) return;\n  box.style.left = `${e.clientX - offsetX}px`;\n  box.style.top = `${e.clientY - offsetY}px`;\n});\n\nbox.addEventListener('mouseup', () => {\n  dragging = false;\n});","code","js",[],{"id":149,"html":150,"type":138},"b4","\u003Cp>This is correct, tested, and works on exactly one category of device.\u003C\u002Fp>",{"id":152,"html":153,"text":154,"type":155,"level":31},"b5","The &quot;fix&quot; that just adds a second bug","The \"fix\" that just adds a second bug","heading",{"id":157,"html":158,"type":138},"b6","\u003Cp>The instinct is to bolt on touch support: copy the three listeners, swap \u003Ccode>mouse*\u003C\u002Fcode> for \u003Ccode>touch*\u003C\u002Fcode>, convert \u003Ccode>e.clientX\u003C\u002Fcode>\u002F\u003Ccode>e.clientY\u003C\u002Fcode> to \u003Ccode>e.touches[0].clientX\u003C\u002Fcode>\u002F\u003Ccode>e.touches[0].clientY\u003C\u002Fcode>, and call it done.\u003C\u002Fp>",{"id":160,"code":161,"type":145,"language":146,"highlight":162},"b7","box.addEventListener('touchstart', (e) => {\n  dragging = true;\n  const t = e.touches[0];\n  offsetX = t.clientX - box.offsetLeft;\n  offsetY = t.clientY - box.offsetTop;\n});\n\nbox.addEventListener('touchmove', (e) => {\n  if (!dragging) return;\n  const t = e.touches[0];\n  box.style.left = `${t.clientX - offsetX}px`;\n  box.style.top = `${t.clientY - offsetY}px`;\n});\n\nbox.addEventListener('touchend', () => {\n  dragging = false;\n});",[],{"id":164,"html":165,"type":138},"b8","\u003Cp>Now it works on a phone, in isolation. But you&#39;re maintaining the same drag math twice, in two event systems that don&#39;t agree on anything — touch events report an array of active touch points (because a screen can have more than one finger on it at once), mouse events report exactly one cursor. And both paths share a bug neither the original mouse-only version nor this touch-added version ever fixed: \u003Cstrong>drag fast enough, or past the edge of the box, and the &quot;end&quot; event stops firing, leaving the card stuck mid-drag.\u003C\u002Fstrong>\u003C\u002Fp>",{"id":167,"html":168,"type":138},"b9","\u003Cp>That&#39;s not a mobile-only bug. It&#39;s the same bug on both input types, because \u003Ccode>mousemove\u003C\u002Fcode> and \u003Ccode>touchmove\u003C\u002Fcode> share the same design flaw.\u003C\u002Fp>",{"id":170,"html":171,"text":172,"type":155,"level":31},"b10","Why the drag &quot;sticks&quot;","Why the drag \"sticks\"",{"id":174,"html":175,"type":138},"b11","\u003Cp>\u003Ccode>mousemove\u003C\u002Fcode> only fires on the element it&#39;s attached to while the cursor is actually over that element — it&#39;s hit-tested live, every event. Flick the mouse fast enough, or drag past the edge of the box, and the browser stops delivering \u003Ccode>mousemove\u003C\u002Fcode> (and eventually \u003Ccode>mouseup\u003C\u002Fcode>) there at all; those events go to whatever&#39;s now under the cursor instead, which has no idea your drag was in progress. The card is left believing it&#39;s still being dragged, because the one event that was supposed to tell it otherwise was never sent to it.\u003C\u002Fp>",{"id":177,"html":178,"type":138},"b12","\u003Cp>Touch has a related but distinct failure mode: if the OS decides your drag gesture is actually a scroll or a browser gesture (a mid-drag notification pull-down, a back-swipe), it can cancel the touch sequence outright, firing \u003Ccode>touchcancel\u003C\u002Fcode> instead of \u003Ccode>touchend\u003C\u002Fcode> — and if you only wired a \u003Ccode>touchend\u003C\u002Fcode> listener because &quot;that&#39;s how the drag ends,&quot; it never gets a chance to clean up.\u003C\u002Fp>",{"id":180,"html":181,"type":138},"b13","\u003Cp>Two input systems, two different reasons, one shared symptom: \u003Cstrong>\u003Ccode>dragging\u003C\u002Fcode> gets stuck \u003Ccode>true\u003C\u002Fcode>, because neither API gives the element a reliable way to say &quot;tell me how this interaction ends, every time, no matter what.&quot;\u003C\u002Fstrong>\u003C\u002Fp>",{"id":183,"html":184,"type":138},"b14","\u003C!-- playground:start -->",{"id":186,"html":187,"text":187,"type":155,"level":31},"b15","🎮 Try it yourself",{"id":189,"html":190,"type":138},"b16","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpointer-events-unify-mouse-touch\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":192,"html":193,"type":138},"b17","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":195,"html":196,"type":138},"b18","\u003C!-- playground:end -->",{"id":198,"html":199,"text":199,"type":155,"level":31},"b19","One event model, built to not have this bug",{"id":201,"html":202,"type":138},"b20","\u003Cp>The \u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FPointer_events\">Pointer Events API\u003C\u002Fa> replaces \u003Ccode>mouse*\u003C\u002Fcode> and \u003Ccode>touch*\u003C\u002Fcode> with a single stream — \u003Ccode>pointerdown\u003C\u002Fcode>, \u003Ccode>pointermove\u003C\u002Fcode>, \u003Ccode>pointerup\u003C\u002Fcode>, \u003Ccode>pointercancel\u003C\u002Fcode> — that fires identically whether the input is a mouse, a finger, or a stylus. You write it once:\u003C\u002Fp>",{"id":204,"code":205,"type":145,"language":146,"highlight":206},"b21","box.addEventListener('pointerdown', (e) => {\n  dragging = true;\n  offsetX = e.clientX - box.offsetLeft;\n  offsetY = e.clientY - box.offsetTop;\n  box.setPointerCapture(e.pointerId);\n});\n\nbox.addEventListener('pointermove', (e) => {\n  if (!dragging) return;\n  box.style.left = `${e.clientX - offsetX}px`;\n  box.style.top = `${e.clientY - offsetY}px`;\n});\n\nfunction endDrag() {\n  dragging = false;\n}\n\nbox.addEventListener('pointerup', endDrag);\nbox.addEventListener('pointercancel', endDrag);",[],{"id":208,"html":209,"type":138},"b22","\u003Cp>Three things changed, and each one closes a specific gap the old code had:\u003C\u002Fp>",{"id":211,"type":212,"items":213,"ordered":17},"b23","list",[214,215,216],"\u003Cstrong>One set of listeners, no branching on input type.\u003C\u002Fstrong> \u003Ccode>e.clientX\u003C\u002Fcode>\u002F\u003Ccode>e.clientY\u003C\u002Fcode> work the same for mouse, touch, and pen — no more reading \u003Ccode>e.touches[0]\u003C\u002Fcode> on one path and \u003Ccode>e.clientX\u003C\u002Fcode> directly on the other.","\u003Cstrong>\u003Ccode>box.setPointerCapture(e.pointerId)\u003C\u002Fcode> is the actual fix for the stuck-drag bug.\u003C\u002Fstrong> Once an element captures a pointer, \u003Cem>every\u003C\u002Fem> event for that pointer — including \u003Ccode>pointermove\u003C\u002Fcode> and \u003Ccode>pointerup\u003C\u002Fcode> — keeps firing on that element, even when the pointer moves outside its boundaries or off the page entirely. The box can&#39;t lose track of the drag because the browser is now routing events to it directly, not based on where the pointer happens to be.","\u003Cstrong>\u003Ccode>pointercancel\u003C\u002Fcode> is the cleanup path the old code didn&#39;t have.\u003C\u002Fstrong> It fires when the browser interrupts the interaction for its own reasons — a touch sequence hijacked into a system gesture, for instance. Listening for it alongside \u003Ccode>pointerup\u003C\u002Fcode> means \u003Ccode>dragging\u003C\u002Fcode> always gets reset, instead of staying stuck on \u003Ccode>true\u003C\u002Fcode> forever.",{"id":218,"html":219,"type":138},"b24","\u003Cp>One more line matters for touch devices specifically: add \u003Ccode>touch-action: none\u003C\u002Fcode> in CSS on the draggable element.\u003C\u002Fp>",{"id":221,"code":222,"type":145,"language":78,"highlight":223},"b25",".box { touch-action: none; }",[],{"id":225,"html":226,"type":138},"b26","\u003Cp>Without it, the browser&#39;s own scroll\u002Fzoom gesture handling can still compete with your drag for the same touch input before your JavaScript gets a say. \u003Ccode>touch-action: none\u003C\u002Fcode> tells the browser &quot;this element handles its own gestures,&quot; which is exactly what you&#39;re asking Pointer Events to do.\u003C\u002Fp>",{"id":228,"html":229,"text":229,"type":155,"level":31},"b27","What you get that mouse-plus-touch never gave you",{"id":231,"type":212,"items":232,"ordered":18},"b28",[233,234,235],"\u003Cstrong>\u003Ccode>e.pointerType\u003C\u002Fcode>\u003C\u002Fstrong> tells you \u003Ccode>&quot;mouse&quot;\u003C\u002Fcode>, \u003Ccode>&quot;touch&quot;\u003C\u002Fcode>, or \u003Ccode>&quot;pen&quot;\u003C\u002Fcode> when you actually need to branch — for a stylus app that wants pressure (\u003Ccode>e.pressure\u003C\u002Fcode>) and tilt (\u003Ccode>e.tiltX\u003C\u002Fcode>\u002F\u003Ccode>e.tiltY\u003C\u002Fcode>) only from a pen, for instance. You branch when the \u003Cem>behavior\u003C\u002Fem> should differ, not just to read the coordinates.","\u003Cstrong>Multi-touch stays sane.\u003C\u002Fstrong> Each contact point gets its own \u003Ccode>pointerId\u003C\u002Fcode>, so a canvas handling two fingers at once tracks two independent pointer streams instead of indexing into a shifting \u003Ccode>touches\u003C\u002Fcode> array.","\u003Cstrong>It&#39;s been safe to rely on for years.\u003C\u002Fstrong> Pointer Events shipped in every major engine — Safari was the last holdout and added support in 2019 — so this isn&#39;t a progressive-enhancement decision anymore; it&#39;s the default way to handle pointing input.",{"id":237,"html":238,"type":138},"b29","\u003C!-- quiz:start -->",{"id":240,"html":241,"text":241,"type":155,"level":31},"b30","🧠 Test yourself",{"id":243,"html":244,"type":138},"b31","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpointer-events-unify-mouse-touch\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":246,"html":247,"type":138},"b32","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":249,"html":250,"type":138},"b33","\u003C!-- quiz:end -->",{"id":252,"html":253,"type":138},"b34","\u003Cp>Pointer Events quietly fixes two separate bugs with one API. The &quot;nothing happens on a phone&quot; bug goes away because there&#39;s only one set of listeners to remember to wire up, not two. And the &quot;drag freezes mid-gesture&quot; bug — the one that was hiding in the mouse-only version from the start, then rode along into the touch-added version unchanged — goes away because \u003Ccode>setPointerCapture()\u003C\u002Fcode> finally gives the element what neither old API did: a guarantee that it keeps hearing from the pointer until the interaction is actually over.\u003C\u002Fp>",{"id":255,"html":256,"type":138},"b35","\u003Cp>Go find the component in your codebase with a \u003Ccode>mousedown\u003C\u002Fcode> handler and a near-identical \u003Ccode>touchstart\u003C\u002Fcode> handler sitting a few lines apart. How much of that duplication would \u003Ccode>pointerdown\u003C\u002Fcode> alone have deleted? Tell me what you find.\u003C\u002Fp>",{"id":258,"html":259,"type":138},"b36","\u003C!-- related:start -->",{"id":261,"html":262,"text":262,"type":155,"level":31},"b37","📚 Read next",{"id":264,"type":212,"items":265,"ordered":18},"b38",[266,267,268],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fserver-sent-events-eventsource-live-updates\">You Don&#39;t Need a WebSocket for That Live Feed\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffile-system-access-api-real-file-read-write\">Your &#39;Save&#39; Button Makes Copies. The File System Access API Doesn&#39;t.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fstorage-event-cross-tab-sync\">I Built Cross-Tab Logout. The storage Event Skipped One Tab.\u003C\u002Fa>",{"id":270,"html":271,"type":138},"b39","\u003C!-- related:end -->",{"id":273,"type":274},"b40","divider",{"id":276,"html":277,"type":138},"b41","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":279,"html":280,"type":138},"b42","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":282,"type":212,"items":283,"ordered":18},"b43",[284,285,286],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","The demo went great. Drag the card, it follows your cursor, drop it anywhere — smooth, on every laptop in the room. Then someone picks up their phone, drags a thumb across the card — and nothing happens. No movement, no feedback, like the card isn't even listening.\n\nYou didn't write a buggy drag handler. You wrote **half** of one.\n\n```js\nbox.addEventListener('mousedown', (e) => {\n  dragging = true;\n  offsetX = e.clientX - box.offsetLeft;\n  offsetY = e.clientY - box.offsetTop;\n});\n\nbox.addEventListener('mousemove', (e) => {\n  if (!dragging) return;\n  box.style.left = `${e.clientX - offsetX}px`;\n  box.style.top = `${e.clientY - offsetY}px`;\n});\n\nbox.addEventListener('mouseup', () => {\n  dragging = false;\n});\n```\n\nThis is correct, tested, and works on exactly one category of device.\n\n## The \"fix\" that just adds a second bug\n\nThe instinct is to bolt on touch support: copy the three listeners, swap `mouse*` for `touch*`, convert `e.clientX`\u002F`e.clientY` to `e.touches[0].clientX`\u002F`e.touches[0].clientY`, and call it done.\n\n```js\nbox.addEventListener('touchstart', (e) => {\n  dragging = true;\n  const t = e.touches[0];\n  offsetX = t.clientX - box.offsetLeft;\n  offsetY = t.clientY - box.offsetTop;\n});\n\nbox.addEventListener('touchmove', (e) => {\n  if (!dragging) return;\n  const t = e.touches[0];\n  box.style.left = `${t.clientX - offsetX}px`;\n  box.style.top = `${t.clientY - offsetY}px`;\n});\n\nbox.addEventListener('touchend', () => {\n  dragging = false;\n});\n```\n\nNow it works on a phone, in isolation. But you're maintaining the same drag math twice, in two event systems that don't agree on anything — touch events report an array of active touch points (because a screen can have more than one finger on it at once), mouse events report exactly one cursor. And both paths share a bug neither the original mouse-only version nor this touch-added version ever fixed: **drag fast enough, or past the edge of the box, and the \"end\" event stops firing, leaving the card stuck mid-drag.**\n\nThat's not a mobile-only bug. It's the same bug on both input types, because `mousemove` and `touchmove` share the same design flaw.\n\n## Why the drag \"sticks\"\n\n`mousemove` only fires on the element it's attached to while the cursor is actually over that element — it's hit-tested live, every event. Flick the mouse fast enough, or drag past the edge of the box, and the browser stops delivering `mousemove` (and eventually `mouseup`) there at all; those events go to whatever's now under the cursor instead, which has no idea your drag was in progress. The card is left believing it's still being dragged, because the one event that was supposed to tell it otherwise was never sent to it.\n\nTouch has a related but distinct failure mode: if the OS decides your drag gesture is actually a scroll or a browser gesture (a mid-drag notification pull-down, a back-swipe), it can cancel the touch sequence outright, firing `touchcancel` instead of `touchend` — and if you only wired a `touchend` listener because \"that's how the drag ends,\" it never gets a chance to clean up.\n\nTwo input systems, two different reasons, one shared symptom: **`dragging` gets stuck `true`, because neither API gives the element a reliable way to say \"tell me how this interaction ends, every time, no matter what.\"**\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpointer-events-unify-mouse-touch\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## One event model, built to not have this bug\n\nThe [Pointer Events API](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FPointer_events) replaces `mouse*` and `touch*` with a single stream — `pointerdown`, `pointermove`, `pointerup`, `pointercancel` — that fires identically whether the input is a mouse, a finger, or a stylus. You write it once:\n\n```js\nbox.addEventListener('pointerdown', (e) => {\n  dragging = true;\n  offsetX = e.clientX - box.offsetLeft;\n  offsetY = e.clientY - box.offsetTop;\n  box.setPointerCapture(e.pointerId);\n});\n\nbox.addEventListener('pointermove', (e) => {\n  if (!dragging) return;\n  box.style.left = `${e.clientX - offsetX}px`;\n  box.style.top = `${e.clientY - offsetY}px`;\n});\n\nfunction endDrag() {\n  dragging = false;\n}\n\nbox.addEventListener('pointerup', endDrag);\nbox.addEventListener('pointercancel', endDrag);\n```\n\nThree things changed, and each one closes a specific gap the old code had:\n\n1. **One set of listeners, no branching on input type.** `e.clientX`\u002F`e.clientY` work the same for mouse, touch, and pen — no more reading `e.touches[0]` on one path and `e.clientX` directly on the other.\n2. **`box.setPointerCapture(e.pointerId)` is the actual fix for the stuck-drag bug.** Once an element captures a pointer, *every* event for that pointer — including `pointermove` and `pointerup` — keeps firing on that element, even when the pointer moves outside its boundaries or off the page entirely. The box can't lose track of the drag because the browser is now routing events to it directly, not based on where the pointer happens to be.\n3. **`pointercancel` is the cleanup path the old code didn't have.** It fires when the browser interrupts the interaction for its own reasons — a touch sequence hijacked into a system gesture, for instance. Listening for it alongside `pointerup` means `dragging` always gets reset, instead of staying stuck on `true` forever.\n\nOne more line matters for touch devices specifically: add `touch-action: none` in CSS on the draggable element.\n\n```css\n.box { touch-action: none; }\n```\n\nWithout it, the browser's own scroll\u002Fzoom gesture handling can still compete with your drag for the same touch input before your JavaScript gets a say. `touch-action: none` tells the browser \"this element handles its own gestures,\" which is exactly what you're asking Pointer Events to do.\n\n## What you get that mouse-plus-touch never gave you\n\n- **`e.pointerType`** tells you `\"mouse\"`, `\"touch\"`, or `\"pen\"` when you actually need to branch — for a stylus app that wants pressure (`e.pressure`) and tilt (`e.tiltX`\u002F`e.tiltY`) only from a pen, for instance. You branch when the *behavior* should differ, not just to read the coordinates.\n- **Multi-touch stays sane.** Each contact point gets its own `pointerId`, so a canvas handling two fingers at once tracks two independent pointer streams instead of indexing into a shifting `touches` array.\n- **It's been safe to rely on for years.** Pointer Events shipped in every major engine — Safari was the last holdout and added support in 2019 — so this isn't a progressive-enhancement decision anymore; it's the default way to handle pointing input.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpointer-events-unify-mouse-touch\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\nPointer Events quietly fixes two separate bugs with one API. The \"nothing happens on a phone\" bug goes away because there's only one set of listeners to remember to wire up, not two. And the \"drag freezes mid-gesture\" bug — the one that was hiding in the mouse-only version from the start, then rode along into the touch-added version unchanged — goes away because `setPointerCapture()` finally gives the element what neither old API did: a guarantee that it keeps hearing from the pointer until the interaction is actually over.\n\nGo find the component in your codebase with a `mousedown` handler and a near-identical `touchstart` handler sitting a few lines apart. How much of that duplication would `pointerdown` alone have deleted? Tell me what you find.\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [You Don't Need a WebSocket for That Live Feed](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fserver-sent-events-eventsource-live-updates)\n- [Your 'Save' Button Makes Copies. The File System Access API Doesn't.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffile-system-access-api-real-file-read-write)\n- [I Built Cross-Tab Logout. The storage Event Skipped One Tab.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fstorage-event-cross-tab-sync)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":110,"canonical":289,"description":290},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpointer-events-unify-mouse-touch","A draggable card that works with mousedown\u002Fmousemove on desktop and breaks the moment you test it on a phone. The fix isn't a second set of touch listeners — it's deleting the mous","01a152c8-59f0-74ca-9c45-71e7ca2252a8",{"id":293,"locked":18},"01a152c8-5a15-7003-ae20-063a40c3658d",[295],{"id":45,"slug":46,"title":48,"_count":296},{"questions":51},[298],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":300,"questionCount":51},{"questions":51},[]]