diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx
index 27a46d76b845bba22ee7563c71eea4f341529807..aa759aeebdfe192684baa65a14e7c21b2870a616 100644
--- a/src/containers/sprite-selector-item.jsx
+++ b/src/containers/sprite-selector-item.jsx
@@ -6,14 +6,12 @@ import {connect} from 'react-redux';
 import {setHoveredSprite} from '../reducers/hovered-target';
 import {updateAssetDrag} from '../reducers/asset-drag';
 import storage from '../lib/storage';
-import {getEventXY} from '../lib/touch-utils';
 import VM from 'scratch-vm';
 import getCostumeUrl from '../lib/get-costume-url';
+import DragRecognizer from '../lib/drag-recognizer';
 
 import SpriteSelectorItemComponent from '../components/sprite-selector-item/sprite-selector-item.jsx';
 
-const dragThreshold = 3; // Same as the block drag threshold
-
 class SpriteSelectorItem extends React.PureComponent {
     constructor (props) {
         super(props);
@@ -26,9 +24,17 @@ class SpriteSelectorItem extends React.PureComponent {
             'handleMouseEnter',
             'handleMouseLeave',
             'handleMouseDown',
-            'handleMouseMove',
-            'handleMouseUp'
+            'handleDragEnd',
+            'handleDrag'
         ]);
+
+        this.dragRecognizer = new DragRecognizer({
+            onDrag: this.handleDrag,
+            onDragEnd: this.handleDragEnd
+        });
+    }
+    componentWillUnmount () {
+        this.dragRecognizer.reset();
     }
     getCostumeData () {
         if (this.props.costumeURL) return this.props.costumeURL;
@@ -36,13 +42,7 @@ class SpriteSelectorItem extends React.PureComponent {
 
         return getCostumeUrl(this.props.asset);
     }
-    handleMouseUp () {
-        this.initialOffset = null;
-        this.gestureIsScroll = null;
-        window.removeEventListener('mouseup', this.handleMouseUp);
-        window.removeEventListener('mousemove', this.handleMouseMove);
-        window.removeEventListener('touchend', this.handleMouseUp);
-        window.removeEventListener('touchmove', this.handleMouseMove, {passive: false});
+    handleDragEnd () {
         if (this.props.dragging) {
             this.props.onDrag({
                 img: null,
@@ -56,55 +56,19 @@ class SpriteSelectorItem extends React.PureComponent {
             this.noClick = false;
         });
     }
-    handleMouseMove (e) {
-        const currentOffset = getEventXY(e);
-
-        let shouldStartDrag = false;
-        if (!this.props.dragging && !this.gestureIsScroll) {
-            const dx = currentOffset.x - this.initialOffset.x;
-            const dy = currentOffset.y - this.initialOffset.y;
-            const dragDistance = Math.sqrt((dx * dx) + (dy * dy));
-            shouldStartDrag = dragDistance > dragThreshold;
-
-            // For touch moves, additionally check if the angle suggests drag vs. scroll
-            if (shouldStartDrag && e.type === 'touchmove') {
-                const angleThreshold = 50;
-                // Direction goes from -180 to 180, with 0 toward the right.
-                let angle = Math.atan2(dy, dx) / Math.PI * 180;
-                // Fold over horizontal axis, range now 0 to 180
-                angle = Math.abs(angle);
-                // Fold over vertical axis, range now 0 to 90
-                if (angle > 90) angle = 180 - angle;
-                shouldStartDrag = shouldStartDrag && angle < angleThreshold;
-
-                // If the movement exceeds the distance threshold but is not a drag by angle
-                // stop checking by setting the gestureIsScroll flag. This prevents
-                // a scroll drag from "turning into" a tile drag by changing the angle.
-                if (!shouldStartDrag) {
-                    this.gestureIsScroll = true;
-                }
-            }
-        }
-
-        if (shouldStartDrag || this.props.dragging) {
-            this.props.onDrag({
-                img: this.getCostumeData(),
-                currentOffset: currentOffset,
-                dragging: true,
-                dragType: this.props.dragType,
-                index: this.props.index,
-                payload: this.props.dragPayload
-            });
-            this.noClick = true;
-            e.preventDefault();
-        }
+    handleDrag (currentOffset) {
+        this.props.onDrag({
+            img: this.getCostumeData(),
+            currentOffset: currentOffset,
+            dragging: true,
+            dragType: this.props.dragType,
+            index: this.props.index,
+            payload: this.props.dragPayload
+        });
+        this.noClick = true;
     }
     handleMouseDown (e) {
-        this.initialOffset = getEventXY(e);
-        window.addEventListener('mouseup', this.handleMouseUp);
-        window.addEventListener('mousemove', this.handleMouseMove);
-        window.addEventListener('touchend', this.handleMouseUp);
-        window.addEventListener('touchmove', this.handleMouseMove, {passive: false});
+        this.dragRecognizer.start(e);
     }
     handleClick (e) {
         e.preventDefault();
diff --git a/src/lib/drag-recognizer.js b/src/lib/drag-recognizer.js
new file mode 100644
index 0000000000000000000000000000000000000000..1f507785ae0474938de79abfe02e3f9d5f19107d
--- /dev/null
+++ b/src/lib/drag-recognizer.js
@@ -0,0 +1,125 @@
+import bindAll from 'lodash.bindall';
+import {getEventXY} from '../lib/touch-utils';
+
+class DragRecognizer {
+    /* Gesture states */
+    static get STATE_UNIDENTIFIED () {
+        return 'unidentified';
+    }
+    static get STATE_SCROLL () {
+        return 'scroll';
+    }
+    static get STATE_DRAG () {
+        return 'drag';
+    }
+
+    constructor ({
+        onDrag = (() => {}),
+        onDragEnd = (() => {}),
+        touchDragAngle = 70, // Angle and distance thresholds are the same as scratch-blocks
+        distanceThreshold = 3
+    }) {
+        this._onDrag = onDrag;
+        this._onDragEnd = onDragEnd;
+        this._touchDragAngle = touchDragAngle;
+        this._distanceThreshold = distanceThreshold;
+
+        this._initialOffset = null;
+        this._gestureState = DragRecognizer.STATE_UNIDENTIFIED;
+
+        bindAll(this, [
+            'start',
+            'gestureInProgress',
+            'reset',
+            '_handleMove',
+            '_handleEnd'
+        ]);
+    }
+
+    start (event) {
+        this._initialOffset = getEventXY(event);
+        this._bindListeners();
+    }
+
+    gestureInProgress () {
+        return this._gestureState !== DragRecognizer.STATE_UNIDENTIFIED;
+    }
+    
+    reset () {
+        this._unbindListeners();
+        this._initialOffset = null;
+        this._gestureState = DragRecognizer.STATE_UNIDENTIFIED;
+    }
+
+    //
+    // Internal functions
+    //
+
+    _bindListeners () {
+        window.addEventListener('mouseup', this._handleEnd);
+        window.addEventListener('mousemove', this._handleMove);
+        window.addEventListener('touchend', this._handleEnd);
+        // touchmove must be marked as non-passive, or else it cannot prevent scrolling
+        window.addEventListener('touchmove', this._handleMove, {passive: false});
+    }
+
+    _unbindListeners () {
+        window.removeEventListener('mouseup', this._handleEnd);
+        window.removeEventListener('mousemove', this._handleMove);
+        window.removeEventListener('touchend', this._handleEnd);
+        window.removeEventListener('touchmove', this._handleMove, {passive: false});
+    }
+
+    _handleMove (event) {
+        // For gestures identified as vertical scrolls, do not process movement events
+        if (this._isScroll()) return;
+
+        const currentOffset = getEventXY(event);
+
+        // Try to identify this gesture if it hasn't been identified already
+        if (!this.gestureInProgress()) {
+            const dx = currentOffset.x - this._initialOffset.x;
+            const dy = currentOffset.y - this._initialOffset.y;
+            const dragDistance = Math.sqrt((dx * dx) + (dy * dy));
+            if (dragDistance < this._distanceThreshold) return;
+
+            // For touch moves, additionally check if the angle suggests drag vs. scroll
+            if (event.type === 'touchmove') {
+                // Direction goes from -180 to 180, with 0 toward the right.
+                let angle = Math.atan2(dy, dx) / Math.PI * 180;
+                // Fold over horizontal axis, range now 0 to 180
+                angle = Math.abs(angle);
+                // Fold over vertical axis, range now 0 to 90
+                if (angle > 90) angle = 180 - angle;
+                if (angle > this._touchDragAngle) {
+                    this._gestureState = DragRecognizer.STATE_SCROLL;
+                } else {
+                    this._gestureState = DragRecognizer.STATE_DRAG;
+                }
+            } else {
+                // Mouse moves are always considered drags
+                this._gestureState = DragRecognizer.STATE_DRAG;
+            }
+        }
+
+        if (this._isDrag()) {
+            this._onDrag(currentOffset, this._initialOffset);
+            event.preventDefault();
+        }
+    }
+
+    _handleEnd () {
+        this._onDragEnd();
+        this.reset();
+    }
+
+    _isDrag () {
+        return this._gestureState === DragRecognizer.STATE_DRAG;
+    }
+
+    _isScroll () {
+        return this._gestureState === DragRecognizer.STATE_SCROLL;
+    }
+}
+
+export default DragRecognizer;
diff --git a/test/unit/util/drag-recognizer.test.js b/test/unit/util/drag-recognizer.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..825917ba8eddb75e2278808166581eec552e5c06
--- /dev/null
+++ b/test/unit/util/drag-recognizer.test.js
@@ -0,0 +1,109 @@
+import DragRecognizer from '../../../src/lib/drag-recognizer';
+
+describe('DragRecognizer', () => {
+    let onDrag;
+    let onDragEnd;
+    let dragRecognizer;
+
+    beforeEach(() => {
+        onDrag = jest.fn();
+        onDragEnd = jest.fn();
+        dragRecognizer = new DragRecognizer({onDrag, onDragEnd});
+    });
+
+    afterEach(() => {
+        dragRecognizer.reset();
+    });
+
+    test('start -> small drag', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        window.dispatchEvent(new MouseEvent('mousemove', {clientX: 101, clientY: 101}));
+        expect(onDrag).not.toBeCalled();
+    });
+
+    test('start -> large vertical touch move -> scroll, not drag', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 106, clientY: 150}));
+        expect(onDrag).not.toBeCalled();
+    });
+
+    test('start -> large vertical mouse move -> mouse moves always drag)', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        window.dispatchEvent(new MouseEvent('mousemove', {clientX: 100, clientY: 150}));
+        expect(onDrag).toBeCalled();
+    });
+
+    test('start -> large horizontal touch move -> drag', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
+        expect(onDrag).toBeCalled();
+    });
+
+    test('after starting a scroll, it cannot become a drag', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 100, clientY: 110}));
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 100, clientY: 100}));
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 110, clientY: 100}));
+        expect(onDrag).not.toBeCalled();
+    });
+
+    test('start -> end unbinds', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
+        expect(onDrag).toHaveBeenCalledTimes(1);
+        window.dispatchEvent(new MouseEvent('touchend', {clientX: 150, clientY: 106}));
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
+        expect(onDrag).toHaveBeenCalledTimes(1); // Still 1
+    });
+
+    test('start -> reset unbinds', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
+        expect(onDrag).toHaveBeenCalledTimes(1);
+        dragRecognizer.reset();
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 150, clientY: 106}));
+        expect(onDrag).toHaveBeenCalledTimes(1); // Still 1
+    });
+
+    test('scrolls do not call prevent default', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        const event = new MouseEvent('touchmove', {clientX: 100, clientY: 110});
+        event.preventDefault = jest.fn();
+        window.dispatchEvent(event);
+        expect(event.preventDefault).toHaveBeenCalledTimes(0);
+    });
+
+    test('confirmed drags have preventDefault called on them', () => {
+        dragRecognizer.start({clientX: 100, clientY: 100});
+        const event = new MouseEvent('touchmove', {clientX: 150, clientY: 106});
+        event.preventDefault = jest.fn();
+        window.dispatchEvent(event);
+        expect(event.preventDefault).toHaveBeenCalled();
+    });
+
+    test('multiple horizontal drag angles', () => {
+        // +45 from horizontal => drag
+        dragRecognizer.start({clientX: 0, clientY: 0});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 10, clientY: 10}));
+        expect(onDrag).toHaveBeenCalledTimes(1);
+        dragRecognizer.reset();
+
+        // -45 from horizontal => drag
+        dragRecognizer.start({clientX: 0, clientY: 0});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: 10, clientY: -10}));
+        expect(onDrag).toHaveBeenCalledTimes(2);
+        dragRecognizer.reset();
+
+        // +135 from horizontal => drag
+        dragRecognizer.start({clientX: 0, clientY: 0});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: -10, clientY: 10}));
+        expect(onDrag).toHaveBeenCalledTimes(3);
+        dragRecognizer.reset();
+
+        // -135 from horizontal => drag
+        dragRecognizer.start({clientX: 0, clientY: 0});
+        window.dispatchEvent(new MouseEvent('touchmove', {clientX: -10, clientY: -10}));
+        expect(onDrag).toHaveBeenCalledTimes(4);
+        dragRecognizer.reset();
+    });
+});