diff --git a/Gruntfile.js b/Gruntfile.js
index 4f7dcc73e7..bbb71d33e5 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -247,6 +247,9 @@ grunt.initConfig( {
 
 				"requirejs/require.js": "requirejs/require.js",
 
+				"jquery-mousewheel/jquery.mousewheel.js": "jquery-mousewheel/jquery.mousewheel.js",
+				"jquery-mousewheel/LICENSE.txt": "jquery-mousewheel/LICENSE.txt",
+
 				"jquery-simulate/jquery.simulate.js": "jquery-simulate/jquery.simulate.js",
 				"jquery-simulate/LICENSE.txt": "jquery-simulate/LICENSE.txt",
 
diff --git a/bower.json b/bower.json
index 3ed76cee9c..eb3187e0c3 100644
--- a/bower.json
+++ b/bower.json
@@ -13,6 +13,7 @@
 	},
 	"devDependencies": {
 		"jquery-color": "3.0.0",
+		"jquery-mousewheel": "3.2.2",
 		"jquery-simulate": "1.1.1",
 		"qunit": "2.19.4",
 		"requirejs": "2.1.14",
diff --git a/external/jquery-mousewheel/LICENSE.txt b/external/jquery-mousewheel/LICENSE.txt
new file mode 100644
index 0000000000..f56b79ae05
--- /dev/null
+++ b/external/jquery-mousewheel/LICENSE.txt
@@ -0,0 +1,36 @@
+Copyright OpenJS Foundation and other contributors, https://openjsf.org/
+
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at https://github.com/jquery/jquery-mousewheel
+
+The following license applies to all parts of this software except as
+documented below:
+
+====
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+====
+
+All files located in the node_modules and external directories are
+externally maintained libraries used by this software which have their
+own licenses; we recommend you read them, as their terms may differ from
+the terms above.
diff --git a/external/jquery-mousewheel/jquery.mousewheel.js b/external/jquery-mousewheel/jquery.mousewheel.js
new file mode 100644
index 0000000000..aec55baf8e
--- /dev/null
+++ b/external/jquery-mousewheel/jquery.mousewheel.js
@@ -0,0 +1,242 @@
+/*!
+ * jQuery Mousewheel 3.2.2
+ * Copyright OpenJS Foundation and other contributors
+ */
+
+( function( factory ) {
+    "use strict";
+
+    if ( typeof define === "function" && define.amd ) {
+
+        // AMD. Register as an anonymous module.
+        define( [ "jquery" ], factory );
+    } else if ( typeof exports === "object" ) {
+
+        // Node/CommonJS style for Browserify
+        module.exports = factory;
+    } else {
+
+        // Browser globals
+        factory( jQuery );
+    }
+} )( function( $ ) {
+    "use strict";
+
+    var nullLowestDeltaTimeout, lowestDelta,
+        modernEvents = !!$.fn.on,
+        toFix  = [ "wheel", "mousewheel", "DOMMouseScroll", "MozMousePixelScroll" ],
+        toBind = ( "onwheel" in window.document || window.document.documentMode >= 9 ) ?
+            [ "wheel" ] : [ "mousewheel", "DomMouseScroll", "MozMousePixelScroll" ],
+        slice  = Array.prototype.slice;
+
+    if ( $.event.fixHooks ) {
+        for ( var i = toFix.length; i; ) {
+            $.event.fixHooks[ toFix[ --i ] ] = $.event.mouseHooks;
+        }
+    }
+
+    var special = $.event.special.mousewheel = {
+        version: "3.2.2",
+
+        setup: function() {
+            if ( this.addEventListener ) {
+                for ( var i = toBind.length; i; ) {
+                    this.addEventListener( toBind[ --i ], handler, false );
+                }
+            } else {
+                this.onmousewheel = handler;
+            }
+
+            // Store the line height and page height for this particular element
+            $.data( this, "mousewheel-line-height", special.getLineHeight( this ) );
+            $.data( this, "mousewheel-page-height", special.getPageHeight( this ) );
+        },
+
+        teardown: function() {
+            if ( this.removeEventListener ) {
+                for ( var i = toBind.length; i; ) {
+                    this.removeEventListener( toBind[ --i ], handler, false );
+                }
+            } else {
+                this.onmousewheel = null;
+            }
+
+            // Clean up the data we added to the element
+            $.removeData( this, "mousewheel-line-height" );
+            $.removeData( this, "mousewheel-page-height" );
+        },
+
+        getLineHeight: function( elem ) {
+            var $elem = $( elem ),
+                $parent = $elem[ "offsetParent" in $.fn ? "offsetParent" : "parent" ]();
+            if ( !$parent.length ) {
+                $parent = $( "body" );
+            }
+            return parseInt( $parent.css( "fontSize" ), 10 ) ||
+                parseInt( $elem.css( "fontSize" ), 10 ) || 16;
+        },
+
+        getPageHeight: function( elem ) {
+            return $( elem ).height();
+        },
+
+        settings: {
+            adjustOldDeltas: true, // see shouldAdjustOldDeltas() below
+            normalizeOffset: true  // calls getBoundingClientRect for each event
+        }
+    };
+
+    $.fn.extend( {
+        mousewheel: function( fn ) {
+            return fn ?
+                this[ modernEvents ? "on" : "bind" ]( "mousewheel", fn ) :
+                this.trigger( "mousewheel" );
+        },
+
+        unmousewheel: function( fn ) {
+            return this[ modernEvents ? "off" : "unbind" ]( "mousewheel", fn );
+        }
+    } );
+
+
+    function handler( event ) {
+        var orgEvent   = event || window.event,
+            args       = slice.call( arguments, 1 ),
+            delta      = 0,
+            deltaX     = 0,
+            deltaY     = 0,
+            absDelta   = 0;
+        event = $.event.fix( orgEvent );
+        event.type = "mousewheel";
+
+        // Old school scrollwheel delta
+        if ( "detail" in orgEvent ) {
+            deltaY = orgEvent.detail * -1;
+        }
+        if ( "wheelDelta" in orgEvent ) {
+            deltaY = orgEvent.wheelDelta;
+        }
+        if ( "wheelDeltaY" in orgEvent ) {
+            deltaY = orgEvent.wheelDeltaY;
+        }
+        if ( "wheelDeltaX" in orgEvent ) {
+            deltaX = orgEvent.wheelDeltaX * -1;
+        }
+
+        // Firefox < 17 horizontal scrolling related to DOMMouseScroll event
+        if ( "axis" in orgEvent && orgEvent.axis === orgEvent.HORIZONTAL_AXIS ) {
+            deltaX = deltaY * -1;
+            deltaY = 0;
+        }
+
+        // Set delta to be deltaY or deltaX if deltaY is 0 for backwards compatability
+        delta = deltaY === 0 ? deltaX : deltaY;
+
+        // New school wheel delta (wheel event)
+        if ( "deltaY" in orgEvent ) {
+            deltaY = orgEvent.deltaY * -1;
+            delta  = deltaY;
+        }
+        if ( "deltaX" in orgEvent ) {
+            deltaX = orgEvent.deltaX;
+            if ( deltaY === 0 ) {
+                delta  = deltaX * -1;
+            }
+        }
+
+        // No change actually happened, no reason to go any further
+        if ( deltaY === 0 && deltaX === 0 ) {
+            return;
+        }
+
+        // Need to convert lines and pages to pixels if we aren't already in pixels
+        // There are three delta modes:
+        //   * deltaMode 0 is by pixels, nothing to do
+        //   * deltaMode 1 is by lines
+        //   * deltaMode 2 is by pages
+        if ( orgEvent.deltaMode === 1 ) {
+            var lineHeight = $.data( this, "mousewheel-line-height" );
+            delta  *= lineHeight;
+            deltaY *= lineHeight;
+            deltaX *= lineHeight;
+        } else if ( orgEvent.deltaMode === 2 ) {
+            var pageHeight = $.data( this, "mousewheel-page-height" );
+            delta  *= pageHeight;
+            deltaY *= pageHeight;
+            deltaX *= pageHeight;
+        }
+
+        // Store lowest absolute delta to normalize the delta values
+        absDelta = Math.max( Math.abs( deltaY ), Math.abs( deltaX ) );
+
+        if ( !lowestDelta || absDelta < lowestDelta ) {
+            lowestDelta = absDelta;
+
+            // Adjust older deltas if necessary
+            if ( shouldAdjustOldDeltas( orgEvent, absDelta ) ) {
+                lowestDelta /= 40;
+            }
+        }
+
+        // Adjust older deltas if necessary
+        if ( shouldAdjustOldDeltas( orgEvent, absDelta ) ) {
+
+            // Divide all the things by 40!
+            delta  /= 40;
+            deltaX /= 40;
+            deltaY /= 40;
+        }
+
+        // Get a whole, normalized value for the deltas
+        delta  = Math[ delta  >= 1 ? "floor" : "ceil" ]( delta  / lowestDelta );
+        deltaX = Math[ deltaX >= 1 ? "floor" : "ceil" ]( deltaX / lowestDelta );
+        deltaY = Math[ deltaY >= 1 ? "floor" : "ceil" ]( deltaY / lowestDelta );
+
+        // Normalise offsetX and offsetY properties
+        if ( special.settings.normalizeOffset && this.getBoundingClientRect ) {
+            var boundingRect = this.getBoundingClientRect();
+            event.offsetX = event.clientX - boundingRect.left;
+            event.offsetY = event.clientY - boundingRect.top;
+        }
+
+        // Add information to the event object
+        event.deltaX = deltaX;
+        event.deltaY = deltaY;
+        event.deltaFactor = lowestDelta;
+
+        // Go ahead and set deltaMode to 0 since we converted to pixels
+        // Although this is a little odd since we overwrite the deltaX/Y
+        // properties with normalized deltas.
+        event.deltaMode = 0;
+
+        // Add event and delta to the front of the arguments
+        args.unshift( event, delta, deltaX, deltaY );
+
+        // Clear out lowestDelta after sometime to better
+        // handle multiple device types that give different
+        // a different lowestDelta
+        // Ex: trackpad = 3 and mouse wheel = 120
+        if ( nullLowestDeltaTimeout ) {
+            window.clearTimeout( nullLowestDeltaTimeout );
+        }
+        nullLowestDeltaTimeout = window.setTimeout( function() {
+            lowestDelta = null;
+        }, 200 );
+
+        return ( $.event.dispatch || $.event.handle ).apply( this, args );
+    }
+
+    function shouldAdjustOldDeltas( orgEvent, absDelta ) {
+
+        // If this is an older event and the delta is divisible by 120,
+        // then we are assuming that the browser is treating this as an
+        // older mouse wheel event and that we should divide the deltas
+        // by 40 to try and get a more usable deltaFactor.
+        // Side note, this actually impacts the reported scroll distance
+        // in older browsers and can cause scrolling to be slower than native.
+        // Turn this off by setting $.event.special.mousewheel.settings.adjustOldDeltas to false.
+        return special.settings.adjustOldDeltas && orgEvent.type === "mousewheel" &&
+            absDelta % 120 === 0;
+    }
+
+} );
diff --git a/tests/lib/helper.js b/tests/lib/helper.js
index 2315c5e19d..2be4c48dea 100644
--- a/tests/lib/helper.js
+++ b/tests/lib/helper.js
@@ -51,6 +51,65 @@ exports.moduleAfterEach = function( assert ) {
 	}
 };
 
+exports.testIframe = function( title, fileName, func, wrapper, iframeStyles ) {
+	if ( !wrapper ) {
+		wrapper = QUnit.test;
+	}
+	wrapper.call( QUnit, title, function( assert ) {
+		var done = assert.async(),
+			$iframe = jQuery( "<iframe></iframe>" )
+				.css( {
+					position: "absolute",
+					top: "0",
+					left: "-600px",
+					width: "500px",
+					zIndex: 1,
+					background: "white"
+				} )
+				.attr( { id: "qunit-fixture-iframe", src: fileName } );
+
+		// Add other iframe styles
+		if ( iframeStyles ) {
+			$iframe.css( iframeStyles );
+		}
+
+		// Test iframes are expected to invoke this via startIframeTest
+		// (cf. iframeTest.js)
+		window.iframeCallback = function() {
+			var args = Array.prototype.slice.call( arguments );
+
+			args.unshift( assert );
+
+			setTimeout( function() {
+				var result;
+
+				this.iframeCallback = undefined;
+
+				result = func.apply( this, args );
+
+				function finish() {
+					func = function() {};
+					$iframe.remove();
+					done();
+				}
+
+				// Wait for promises returned by `func`.
+				if ( result && result.then ) {
+					result.then( finish );
+				} else {
+					finish();
+				}
+			} );
+		};
+
+		// Attach iframe to the body for visibility-dependent code.
+		// It will be removed by either the above code, or the testDone
+		// callback in qunit.js.
+		$iframe.prependTo( document.body );
+	} );
+};
+window.iframeCallback = undefined;
+
 return exports;
 
 } );
diff --git a/tests/lib/qunit.js b/tests/lib/qunit.js
index 6441019bdd..c4c96ef582 100644
--- a/tests/lib/qunit.js
+++ b/tests/lib/qunit.js
@@ -7,6 +7,8 @@ define( [
 ], function( QUnit, $ ) {
 "use strict";
 
+var ajaxSettings = $.ajaxSettings;
+
 QUnit.config.autostart = false;
 QUnit.config.requireExpects = true;
 
@@ -34,16 +36,21 @@ QUnit.config.urlConfig.push( {
 	label: "Enable jquery-migrate"
 } );
 
-QUnit.reset = ( function( reset ) {
-	return function() {
+QUnit.testDone( function() {
+
+	// Ensure jQuery events and data on the fixture are properly removed
+	$( "#qunit-fixture" ).empty();
 
-		// Ensure jQuery events and data on the fixture are properly removed
-		$( "#qunit-fixture" ).empty();
+	// Remove the iframe fixture
+	$( "#qunit-fixture-iframe" ).remove();
 
-		// Let QUnit reset the fixture
-		reset.apply( this, arguments );
-	};
-} )( QUnit.reset );
+	// Reset internal $ state
+	if ( ajaxSettings ) {
+		$.ajaxSettings = $.extend( true, {}, ajaxSettings );
+	} else {
+		delete $.ajaxSettings;
+	}
+} );
 
 return QUnit;
 
diff --git a/tests/lib/testIframe.js b/tests/lib/testIframe.js
new file mode 100644
index 0000000000..4db56833c5
--- /dev/null
+++ b/tests/lib/testIframe.js
@@ -0,0 +1,7 @@
+window.startIframeTest = function() {
+	var args = Array.prototype.slice.call( arguments );
+
+	// Note: jQuery may be undefined if page did not load it
+	args.unshift( window.jQuery, window, document );
+	window.parent.iframeCallback.apply( null, args );
+};
diff --git a/tests/unit/spinner/core.js b/tests/unit/spinner/core.js
index befe439f6b..42bcc7bb58 100644
--- a/tests/unit/spinner/core.js
+++ b/tests/unit/spinner/core.js
@@ -239,6 +239,20 @@ QUnit.test( "mousewheel on input (DEPRECATED)", function( assert ) {
 	}
 } );
 
+helper.testIframe(
+	"wheel & mousewheel conflicts",
+	"mousewheel-wheel.html",
+	function( assert, jQuery, window, document, values ) {
+		assert.expect( 5 );
+
+		assert.equal( values[ 0 ], 0, "wheel event without delta does not change value" );
+		assert.equal( values[ 1 ], 2, "delta -1" );
+		assert.equal( values[ 2 ], 0, "delta 0.2" );
+		assert.equal( values[ 3 ], -2, "delta 15" );
+		assert.equal( values[ 4 ], -2, "wheel when not focused" );
+	}
+);
+
 QUnit.test( "reading HTML5 attributes", function( assert ) {
 	assert.expect( 6 );
 	var markup = "<input type='number' min='-100' max='100' value='5' step='2'>",
diff --git a/tests/unit/spinner/mousewheel-wheel.html b/tests/unit/spinner/mousewheel-wheel.html
new file mode 100644
index 0000000000..e512a36ccb
--- /dev/null
+++ b/tests/unit/spinner/mousewheel-wheel.html
@@ -0,0 +1,72 @@
+<!doctype html>
+<html lang="en">
+<head>
+	<meta charset="utf-8">
+	<title>jQuery UI Spinner Test Suite</title>
+
+	<script src="../../../external/requirejs/require.js"></script>
+	<script src="../../../external/jquery/jquery.js"></script>
+	<script src="../../lib/css.js" data-modules="core button spinner theme"></script>
+	<script src="../../lib/testIframe.js"></script>
+</head>
+<body>
+
+<input id="spin" class="foo">
+
+<script>
+	function runTest() {
+		var values = [],
+			element = $( "#spin" ).val( 0 ).spinner( {
+				step: 2
+			} );
+
+		element.focus();
+		setTimeout( step1 );
+
+		function dispatchWheelEvent( elem, deltaY ) {
+			elem[ 0 ].dispatchEvent( new WheelEvent( "wheel", {
+				deltaY: deltaY
+			} ) );
+		}
+
+		function step1() {
+			dispatchWheelEvent( element );
+			values.push( element.val() );
+
+			dispatchWheelEvent( element, -1 );
+			values.push( element.val() );
+
+			dispatchWheelEvent( element, 0.2 );
+			values.push( element.val() );
+
+			dispatchWheelEvent( element, 15 );
+			values.push( element.val() );
+
+			element.blur();
+			setTimeout( step2 );
+		}
+
+		function step2() {
+			dispatchWheelEvent( element, -1 );
+			values.push( element.val() );
+
+			startIframeTest( values );
+		}
+	}
+
+	requirejs.config( {
+		paths: {
+			"jquery-mousewheel": "../../../external/jquery-mousewheel/jquery.mousewheel",
+			"ui": "../../../ui"
+		},
+	} );
+
+	require( [
+		"jquery-mousewheel",
+		"ui/widgets/spinner"
+	], function() {
+		runTest();
+	} );
+</script>
+</body>
+</html>
diff --git a/ui/widgets/spinner.js b/ui/widgets/spinner.js
index 4fb41d7bb6..d4034b4589 100644
--- a/ui/widgets/spinner.js
+++ b/ui/widgets/spinner.js
@@ -164,6 +164,13 @@ $.widget( "ui.spinner", {
 		// event. The `delta` parameter is provided by the jQuery Mousewheel
 		// plugin if one is loaded.
 		mousewheel: function( event, delta ) {
+			if ( !event.isTrigger ) {
+
+				// If this is not a trigger call, the `wheel` handler will
+				// fire as well, let's not duplicate it.
+				return;
+			}
+
 			var wheelEvent = $.Event( event );
 			wheelEvent.type = "wheel";
 			if ( delta ) {