diff --git a/draftlogs/7371_add.md b/draftlogs/7371_add.md
new file mode 100644
index 00000000000..f134f571ff9
--- /dev/null
+++ b/draftlogs/7371_add.md
@@ -0,0 +1 @@
+ - Add `minscale`, `maxscale` geo plot attributes [[#7371](https://github.com/plotly/plotly.js/pull/7371)]
diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index 82c5dff9a48..8ebe360ebc0 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -543,9 +543,22 @@ function handleGeo(gd, ev) {
 
         if(attr === 'zoom') {
             var scale = geoLayout.projection.scale;
+            var minscale = geoLayout.projection.minscale;
+            var maxscale = geoLayout.projection.maxscale === -1 ? Infinity : geoLayout.projection.maxscale;
+            var max = Math.max(minscale, maxscale);
+            var min = Math.min(minscale, maxscale);
             var newScale = (val === 'in') ? 2 * scale : 0.5 * scale;
 
-            Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale);
+            // make sure the scale is within the min/max bounds
+            if (newScale > max) {
+                newScale = max;
+            } else if (newScale < min) {
+                newScale = min;
+            }
+
+            if (newScale !== scale) {
+                Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale);
+            }
         }
     }
 
diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js
index 797ab8373b6..d3b7ee4ec13 100644
--- a/src/plots/geo/geo.js
+++ b/src/plots/geo/geo.js
@@ -484,7 +484,11 @@ proto.updateFx = function(fullLayout, geoLayout) {
 
     if(dragMode === 'pan') {
         bgRect.node().onmousedown = null;
-        bgRect.call(createGeoZoom(_this, geoLayout));
+        var zoom = createGeoZoom(_this, geoLayout)
+        bgRect.call(zoom);
+        // TODO: Figure out how to restrict when this transition occurs. Or is it a no-op if nothing has changed?
+        // Trigger transition to handle if minscale attribute isn't 0
+        zoom.event(bgRect)
         bgRect.on('dblclick.zoom', zoomReset);
         if(!gd._context._scrollZoom.geo) {
             bgRect.on('wheel.zoom', null);
@@ -709,6 +713,15 @@ function getProjection(geoLayout) {
 
     projection.precision(constants.precision);
 
+    // https://d3js.org/d3-zoom#zoom_scaleExtent
+    projection.scaleExtent = () => {
+        var minscale = projLayout.minscale;
+        var maxscale = projLayout.maxscale === -1 ? Infinity : projLayout.maxscale;
+        var max = Math.max(minscale, maxscale);
+        var min = Math.min(minscale, maxscale);
+        return [100 * min, 100 * max];
+    };
+    
     if(geoLayout._isSatellite) {
         projection.tilt(projLayout.tilt).distance(projLayout.distance);
     }
diff --git a/src/plots/geo/layout_attributes.js b/src/plots/geo/layout_attributes.js
index b94cca3bec5..aa9e6e59acc 100644
--- a/src/plots/geo/layout_attributes.js
+++ b/src/plots/geo/layout_attributes.js
@@ -177,6 +177,26 @@ var attrs = module.exports = overrideAll({
                 'that fits the map\'s lon and lat ranges. '
             ].join(' ')
         },
+        minscale: {
+            valType: 'number',
+            min: 0,
+            dflt: 0,
+            description: [
+                'Minimal zoom level of the map view.',
+                'A minscale of *0.5* (50%) corresponds to a zoom level',
+                'where the map has half the size of base zoom level.'
+            ].join(' ')
+        },
+        maxscale: {
+            valType: 'number',
+            min: 0,
+            dflt: -1,
+            description: [
+                'Maximal zoom level of the map view.',
+                'A maxscale of *2* (200%) corresponds to a zoom level',
+                'where the map is twice as big as the base layer.'
+            ].join(' ')
+        },
     },
     center: {
         lon: {
diff --git a/src/plots/geo/layout_defaults.js b/src/plots/geo/layout_defaults.js
index a5d64e2e9f8..8848f5b7f05 100644
--- a/src/plots/geo/layout_defaults.js
+++ b/src/plots/geo/layout_defaults.js
@@ -161,6 +161,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
     }
 
     coerce('projection.scale');
+    coerce('projection.minscale');
+    coerce('projection.maxscale');
 
     show = coerce('showland', !visible ? false : undefined);
     if(show) coerce('landcolor');
@@ -205,6 +207,8 @@ function handleGeoDefaults(geoLayoutIn, geoLayoutOut, coerce, opts) {
     // clear attributes that will get auto-filled later
     if(fitBounds) {
         delete geoLayoutOut.projection.scale;
+        delete geoLayoutOut.projection.minscale;
+        delete geoLayoutOut.projection.maxscale;
 
         if(isScoped) {
             delete geoLayoutOut.center.lon;
diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js
index 2d79d69f581..6cfe1e8f0df 100644
--- a/src/plots/geo/zoom.js
+++ b/src/plots/geo/zoom.js
@@ -32,6 +32,7 @@ module.exports = createGeoZoom;
 function initZoom(geo, projection) {
     return d3.behavior.zoom()
         .translate(projection.translate())
+        .scaleExtent(projection.scaleExtent())
         .scale(projection.scale());
 }
 
@@ -132,7 +133,10 @@ function zoomNonClipped(geo, projection) {
     function handleZoomstart() {
         d3.select(this).style(zoomstartStyle);
 
-        mouse0 = d3.mouse(this);
+        var rect = this.getBBox()
+        mouse0 = d3.event.sourceEvent
+            ? d3.mouse(this)
+            : [rect.x + rect.width / 2, rect.y + rect.height / 2];
         rotate0 = projection.rotate();
         translate0 = projection.translate();
         lastRotate = rotate0;
@@ -140,8 +144,10 @@ function zoomNonClipped(geo, projection) {
     }
 
     function handleZoom() {
-        mouse1 = d3.mouse(this);
-
+        var rect = this.getBBox()
+        mouse1 = d3.event.sourceEvent
+            ? d3.mouse(this)
+            : [rect.x + rect.width / 2, rect.y + rect.height / 2];
         if(outside(mouse0)) {
             zoom.scale(projection.scale());
             zoom.translate(projection.translate());
@@ -210,7 +216,10 @@ function zoomClipped(geo, projection) {
     zoom.on('zoomstart', function() {
         d3.select(this).style(zoomstartStyle);
 
-        var mouse0 = d3.mouse(this);
+        var rect = this.getBBox()
+        var mouse0 = d3.event.sourceEvent
+            ? d3.mouse(this)
+            : [rect.x + rect.width / 2, rect.y + rect.height / 2];
         var rotate0 = projection.rotate();
         var lastRotate = rotate0;
         var translate0 = projection.translate();
@@ -219,7 +228,10 @@ function zoomClipped(geo, projection) {
         zoomPoint = position(projection, mouse0);
 
         zoomOn.call(zoom, 'zoom', function() {
-            var mouse1 = d3.mouse(this);
+            var rect = this.getBBox()
+            var mouse1 = d3.event.sourceEvent
+                ? d3.mouse(this)
+                : [rect.x + rect.width / 2, rect.y + rect.height / 2];
 
             projection.scale(view.k = d3.event.scale);
 
diff --git a/test/plot-schema.json b/test/plot-schema.json
index 6aa77cf3338..3eca5b100d7 100644
--- a/test/plot-schema.json
+++ b/test/plot-schema.json
@@ -2373,6 +2373,20 @@
       "valType": "number"
      },
      "editType": "plot",
+     "maxscale": {
+      "description": "Maximal zoom level of the map view. A maxscale of *2* (200%) corresponds to a zoom level where the map is twice as big as the base layer.",
+      "dflt": -1,
+      "editType": "plot",
+      "min": 0,
+      "valType": "number"
+     },
+     "minscale": {
+      "description": "Minimal zoom level of the map view. A minscale of *0.5* (50%) corresponds to a zoom level where the map has half the size of base zoom level.",
+      "dflt": 0,
+      "editType": "plot",
+      "min": 0,
+      "valType": "number"
+     },
      "parallels": {
       "description": "For conic projection types only. Sets the parallels (tangent, secant) where the cone intersects the sphere.",
       "editType": "plot",