diff --git a/src/components/sliders/attributes.js b/src/components/sliders/attributes.js
index 420287f7ded..cb0203b7801 100644
--- a/src/components/sliders/attributes.js
+++ b/src/components/sliders/attributes.js
@@ -68,7 +68,7 @@ module.exports = {
     active: {
         valType: 'number',
         role: 'info',
-        min: -10,
+        min: 0,
         dflt: 0,
         description: [
             'Determines which button (by index starting from 0) is',
diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js
index c24e322c385..953d131ad17 100644
--- a/src/components/sliders/draw.js
+++ b/src/components/sliders/draw.js
@@ -11,7 +11,6 @@
 
 var d3 = require('d3');
 
-var Plotly = require('../../plotly');
 var Plots = require('../../plots/plots');
 var Lib = require('../../lib');
 var Color = require('../color');
@@ -52,6 +51,9 @@ module.exports = function draw(gd) {
     sliderGroups.exit().each(function(sliderOpts) {
         d3.select(this).remove();
 
+        sliderOpts._commandObserver.remove();
+        delete sliderOpts._commandObserver;
+
         Plots.autoMargin(gd, constants.autoMarginIdRoot + sliderOpts._index);
     });
 
@@ -65,12 +67,20 @@ module.exports = function draw(gd) {
         // If it has fewer than two options, it's not really a slider:
         if(sliderOpts.steps.length < 2) return;
 
+        var gSlider = d3.select(this);
+
         computeLabelSteps(sliderOpts);
 
+        Plots.manageCommandObserver(gd, sliderOpts, sliderOpts.steps, function(data) {
+            if(sliderOpts.active === data.index) return;
+            if(sliderOpts._dragging) return;
+
+            setActive(gd, gSlider, sliderOpts, data.index, false, true);
+        });
+
         drawSlider(gd, d3.select(this), sliderOpts);
 
         // makeInputProxy(gd, d3.select(this), sliderOpts);
-
     });
 };
 
@@ -227,7 +237,9 @@ function drawSlider(gd, sliderGroup, sliderOpts) {
     // Position the rectangle:
     Lib.setTranslate(sliderGroup, sliderOpts.lx + sliderOpts.pad.l, sliderOpts.ly + sliderOpts.pad.t);
 
-    setActive(gd, sliderGroup, sliderOpts, sliderOpts.active, false, false);
+    sliderGroup.call(setGripPosition, sliderOpts, sliderOpts.active / (sliderOpts.steps.length - 1), false);
+    sliderGroup.call(drawCurrentValue, sliderOpts);
+
 }
 
 function drawCurrentValue(sliderGroup, sliderOpts, valueOverride) {
@@ -371,19 +383,9 @@ function setActive(gd, sliderGroup, sliderOpts, index, doCallback, doTransition)
             sliderGroup._nextMethod = {step: step, doCallback: doCallback, doTransition: doTransition};
             sliderGroup._nextMethodRaf = window.requestAnimationFrame(function() {
                 var _step = sliderGroup._nextMethod.step;
-                var args = _step.args;
                 if(!_step.method) return;
 
-                sliderOpts._invokingCommand = true;
-                Plotly[_step.method](gd, args[0], args[1], args[2]).then(function() {
-                    sliderOpts._invokingCommand = false;
-                }, function() {
-                    sliderOpts._invokingCommand = false;
-
-                    // This is not a disaster. Some methods like `animate` reject if interrupted
-                    // and *should* nicely log a warning.
-                    Lib.warn('Warning: Plotly.' + _step.method + ' was called and rejected.');
-                });
+                Plots.executeAPICommand(gd, _step.method, _step.args);
 
                 sliderGroup._nextMethod = null;
                 sliderGroup._nextMethodRaf = null;
@@ -405,6 +407,7 @@ function attachGripEvents(item, gd, sliderGroup, sliderOpts) {
 
         var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]);
         handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, true);
+        sliderOpts._dragging = true;
 
         $gd.on('mousemove', function() {
             var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]);
@@ -412,6 +415,7 @@ function attachGripEvents(item, gd, sliderGroup, sliderOpts) {
         });
 
         $gd.on('mouseup', function() {
+            sliderOpts._dragging = false;
             grip.call(Color.fill, sliderOpts.bgcolor);
             $gd.on('mouseup', null);
             $gd.on('mousemove', null);
@@ -467,8 +471,12 @@ function setGripPosition(sliderGroup, sliderOpts, position, doTransition) {
 
     var x = normalizedValueToPosition(sliderOpts, position);
 
+    // If this is true, then *this component* is already invoking its own command
+    // and has triggered its own animation.
+    if(sliderOpts._invokingCommand) return;
+
     var el = grip;
-    if(doTransition && sliderOpts.transition.duration > 0 && !sliderOpts._invokingCommand) {
+    if(doTransition && sliderOpts.transition.duration > 0) {
         el = el.transition()
             .duration(sliderOpts.transition.duration)
             .ease(sliderOpts.transition.easing);
diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js
index 6da09277e69..39abd0fcf11 100644
--- a/src/components/updatemenus/draw.js
+++ b/src/components/updatemenus/draw.js
@@ -11,7 +11,6 @@
 
 var d3 = require('d3');
 
-var Plotly = require('../../plotly');
 var Plots = require('../../plots/plots');
 var Lib = require('../../lib');
 var Color = require('../color');
@@ -21,7 +20,6 @@ var anchorUtils = require('../legend/anchor_utils');
 
 var constants = require('./constants');
 
-
 module.exports = function draw(gd) {
     var fullLayout = gd._fullLayout,
         menuData = makeMenuData(fullLayout);
@@ -115,6 +113,11 @@ module.exports = function draw(gd) {
     headerGroups.each(function(menuOpts) {
         var gHeader = d3.select(this);
 
+        var _gButton = menuOpts.type === 'dropdown' ? gButton : null;
+        Plots.manageCommandObserver(gd, menuOpts, menuOpts.buttons, function(data) {
+            setActive(gd, menuOpts, menuOpts.buttons[data.index], gHeader, _gButton, data.index, true);
+        });
+
         if(menuOpts.type === 'dropdown') {
             drawHeader(gd, gHeader, gButton, menuOpts);
 
@@ -293,21 +296,9 @@ function drawButtons(gd, gHeader, gButton, menuOpts) {
             .call(setItemPosition, menuOpts, posOpts);
 
         button.on('click', function() {
-            // update 'active' attribute in menuOpts
-            menuOpts._input.active = menuOpts.active = buttonIndex;
-
-            // fold up buttons and redraw header
-            gButton.attr(constants.menuIndexAttrName, '-1');
+            setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex);
 
-            if(menuOpts.type === 'dropdown') {
-                drawHeader(gd, gHeader, gButton, menuOpts);
-            }
-
-            drawButtons(gd, gHeader, gButton, menuOpts);
-
-            // call button method
-            var args = buttonOpts.args;
-            Plotly[buttonOpts.method](gd, args[0], args[1], args[2]);
+            Plots.executeAPICommand(gd, buttonOpts.method, buttonOpts.args);
         });
 
         button.on('mouseover', function() {
@@ -326,6 +317,22 @@ function drawButtons(gd, gHeader, gButton, menuOpts) {
     Lib.setTranslate(gButton, menuOpts.lx, menuOpts.ly);
 }
 
+function setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex, isSilentUpdate) {
+    // update 'active' attribute in menuOpts
+    menuOpts._input.active = menuOpts.active = buttonIndex;
+
+    if(menuOpts.type === 'dropdown') {
+        // fold up buttons and redraw header
+        gButton.attr(constants.menuIndexAttrName, '-1');
+
+        drawHeader(gd, gHeader, gButton, menuOpts);
+    }
+
+    if(!isSilentUpdate || menuOpts.type === 'buttons') {
+        drawButtons(gd, gHeader, gButton, menuOpts);
+    }
+}
+
 function drawItem(item, menuOpts, itemOpts) {
     item.call(drawItemRect, menuOpts)
         .call(drawItemText, menuOpts, itemOpts);
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index 9b1976b4f7f..ad6fea341df 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -2302,14 +2302,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
             var newFrame = trans._currentFrame = trans._frameQueue.shift();
 
             if(newFrame) {
-                gd.emit('plotly_animatingframe', {
-                    name: newFrame.name,
-                    frame: newFrame.frame,
-                    animation: {
-                        frame: newFrame.frameOpts,
-                        transition: newFrame.transitionOpts,
-                    }
-                });
+                gd._fullLayout._currentFrame = newFrame.name;
 
                 trans._lastFrameAt = Date.now();
                 trans._timeToNext = newFrame.frameOpts.duration;
@@ -2324,6 +2317,15 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
                     newFrame.frameOpts,
                     newFrame.transitionOpts
                 );
+
+                gd.emit('plotly_animatingframe', {
+                    name: newFrame.name,
+                    frame: newFrame.frame,
+                    animation: {
+                        frame: newFrame.frameOpts,
+                        transition: newFrame.transitionOpts,
+                    }
+                });
             } else {
                 // If there are no more frames, then stop the RAF loop:
                 stopAnimationLoop();
diff --git a/src/plots/command.js b/src/plots/command.js
new file mode 100644
index 00000000000..cb3b42fb8d3
--- /dev/null
+++ b/src/plots/command.js
@@ -0,0 +1,410 @@
+/**
+* Copyright 2012-2016, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+
+'use strict';
+
+var Plotly = require('../plotly');
+var Lib = require('../lib');
+
+/*
+ * Create or update an observer. This function is designed to be
+ * idempotent so that it can be called over and over as the component
+ * updates, and will attach and detach listeners as needed.
+ *
+ * @param {optional object} container
+ *      An object on which the observer is stored. This is the mechanism
+ *      by which it is idempotent. If it already exists, another won't be
+ *      added. Each time it's called, the value lookup table is updated.
+ * @param {array} commandList
+ *      An array of commands, following either `buttons` of `updatemenus`
+ *      or `steps` of `sliders`.
+ * @param {function} onchange
+ *      A listener called when the value is changed. Receives data object
+ *      with information about the new state.
+ */
+exports.manageCommandObserver = function(gd, container, commandList, onchange) {
+    var ret = {};
+    var enabled = true;
+
+    if(container && container._commandObserver) {
+        ret = container._commandObserver;
+    }
+
+    if(!ret.cache) {
+        ret.cache = {};
+    }
+
+    // Either create or just recompute this:
+    ret.lookupTable = {};
+
+    var binding = exports.hasSimpleAPICommandBindings(gd, commandList, ret.lookupTable);
+
+    if(container && container._commandObserver) {
+        if(!binding) {
+            // If container exists and there are no longer any bindings,
+            // remove existing:
+            if(container._commandObserver.remove) {
+                container._commandObserver.remove();
+                container._commandObserver = null;
+                return ret;
+            }
+        } else {
+            // If container exists and there *are* bindings, then the lookup
+            // table should have been updated and check is already attached,
+            // so there's nothing to be done:
+            return ret;
+
+
+        }
+    }
+
+    // Determine whether there's anything to do for this binding:
+
+    if(binding) {
+        // Build the cache:
+        bindingValueHasChanged(gd, binding, ret.cache);
+
+        ret.check = function check() {
+            if(!enabled) return;
+
+            var update = bindingValueHasChanged(gd, binding, ret.cache);
+
+            if(update.changed && onchange) {
+                // Disable checks for the duration of this command in order to avoid
+                // infinite loops:
+                if(ret.lookupTable[update.value] !== undefined) {
+                    ret.disable();
+                    Promise.resolve(onchange({
+                        value: update.value,
+                        type: binding.type,
+                        prop: binding.prop,
+                        traces: binding.traces,
+                        index: ret.lookupTable[update.value]
+                    })).then(ret.enable, ret.enable);
+                }
+            }
+
+            return update.changed;
+        };
+
+        var checkEvents = [
+            'plotly_relayout',
+            'plotly_redraw',
+            'plotly_restyle',
+            'plotly_update',
+            'plotly_animatingframe',
+            'plotly_afterplot'
+        ];
+
+        for(var i = 0; i < checkEvents.length; i++) {
+            gd._internalOn(checkEvents[i], ret.check);
+        }
+
+        ret.remove = function() {
+            for(var i = 0; i < checkEvents.length; i++) {
+                gd._removeInternalListener(checkEvents[i], ret.check);
+            }
+        };
+    } else {
+        // TODO: It'd be really neat to actually give a *reason* for this, but at least a warning
+        // is a start
+        Lib.warn('Unable to automatically bind plot updates to API command');
+
+        ret.lookupTable = {};
+        ret.remove = function() {};
+    }
+
+    ret.disable = function disable() {
+        enabled = false;
+    };
+
+    ret.enable = function enable() {
+        enabled = true;
+    };
+
+    if(container) {
+        container._commandObserver = ret;
+    }
+
+    return ret;
+};
+
+/*
+ * This function checks to see if an array of objects containing
+ * method and args properties is compatible with automatic two-way
+ * binding. The criteria right now are that
+ *
+ *   1. multiple traces may be affected
+ *   2. only one property may be affected
+ *   3. the same property must be affected by all commands
+ */
+exports.hasSimpleAPICommandBindings = function(gd, commandList, bindingsByValue) {
+    var n = commandList.length;
+
+    var refBinding;
+
+    for(var i = 0; i < n; i++) {
+        var binding;
+        var command = commandList[i];
+        var method = command.method;
+        var args = command.args;
+
+        // If any command has no method, refuse to bind:
+        if(!method) {
+            return false;
+        }
+        var bindings = exports.computeAPICommandBindings(gd, method, args);
+
+        // Right now, handle one and *only* one property being set:
+        if(bindings.length !== 1) {
+            return false;
+        }
+
+        if(!refBinding) {
+            refBinding = bindings[0];
+            if(Array.isArray(refBinding.traces)) {
+                refBinding.traces.sort();
+            }
+        } else {
+            binding = bindings[0];
+            if(binding.type !== refBinding.type) {
+                return false;
+            }
+            if(binding.prop !== refBinding.prop) {
+                return false;
+            }
+            if(Array.isArray(refBinding.traces)) {
+                if(Array.isArray(binding.traces)) {
+                    binding.traces.sort();
+                    for(var j = 0; j < refBinding.traces.length; j++) {
+                        if(refBinding.traces[j] !== binding.traces[j]) {
+                            return false;
+                        }
+                    }
+                } else {
+                    return false;
+                }
+            } else {
+                if(binding.prop !== refBinding.prop) {
+                    return false;
+                }
+            }
+        }
+
+        binding = bindings[0];
+        var value = binding.value;
+        if(Array.isArray(value)) {
+            value = value[0];
+        }
+        if(bindingsByValue) {
+            bindingsByValue[value] = i;
+        }
+    }
+
+    return refBinding;
+};
+
+function bindingValueHasChanged(gd, binding, cache) {
+    var container, value, obj;
+    var changed = false;
+
+    if(binding.type === 'data') {
+        // If it's data, we need to get a trace. Based on the limited scope
+        // of what we cover, we can just take the first trace from the list,
+        // or otherwise just the first trace:
+        container = gd._fullData[binding.traces !== null ? binding.traces[0] : 0];
+    } else if(binding.type === 'layout') {
+        container = gd._fullLayout;
+    } else {
+        return false;
+    }
+
+    value = Lib.nestedProperty(container, binding.prop).get();
+
+    obj = cache[binding.type] = cache[binding.type] || {};
+
+    if(obj.hasOwnProperty(binding.prop)) {
+        if(obj[binding.prop] !== value) {
+            changed = true;
+        }
+    }
+
+    obj[binding.prop] = value;
+
+    return {
+        changed: changed,
+        value: value
+    };
+}
+
+/*
+ * Execute an API command. There's really not much to this; it just provides
+ * a common hook so that implementations don't need to be synchronized across
+ * multiple components with the ability to invoke API commands.
+ *
+ * @param {string} method
+ *      The name of the plotly command to execute. Must be one of 'animate',
+ *      'restyle', 'relayout', 'update'.
+ * @param {array} args
+ *      A list of arguments passed to the API command
+ */
+exports.executeAPICommand = function(gd, method, args) {
+    var apiMethod = Plotly[method];
+
+    var allArgs = [gd];
+    for(var i = 0; i < args.length; i++) {
+        allArgs.push(args[i]);
+    }
+
+    return apiMethod.apply(null, allArgs).catch(function(err) {
+        Lib.warn('API call to Plotly.' + method + ' rejected.', err);
+        return Promise.reject(err);
+    });
+};
+
+exports.computeAPICommandBindings = function(gd, method, args) {
+    var bindings;
+    switch(method) {
+        case 'restyle':
+            bindings = computeDataBindings(gd, args);
+            break;
+        case 'relayout':
+            bindings = computeLayoutBindings(gd, args);
+            break;
+        case 'update':
+            bindings = computeDataBindings(gd, [args[0], args[2]])
+                .concat(computeLayoutBindings(gd, [args[1]]));
+            break;
+        case 'animate':
+            bindings = computeAnimateBindings(gd, args);
+            break;
+        default:
+            // This is the case where intelligent logic about what affects
+            // this command is not implemented. It causes no ill effects.
+            // For example, addFrames simply won't bind to a control component.
+            bindings = [];
+    }
+    return bindings;
+};
+
+function computeAnimateBindings(gd, args) {
+    // We'll assume that the only relevant modification an animation
+    // makes that's meaningfully tracked is the frame:
+    if(Array.isArray(args[0]) && args[0].length === 1 && typeof args[0][0] === 'string') {
+        return [{type: 'layout', prop: '_currentFrame', value: args[0][0]}];
+    } else {
+        return [];
+    }
+}
+
+function computeLayoutBindings(gd, args) {
+    var bindings = [];
+
+    var astr = args[0];
+    var aobj = {};
+    if(typeof astr === 'string') {
+        aobj[astr] = args[1];
+    } else if(Lib.isPlainObject(astr)) {
+        aobj = astr;
+    } else {
+        return bindings;
+    }
+
+    crawl(aobj, function(path, attrName, attr) {
+        bindings.push({type: 'layout', prop: path, value: attr});
+    }, '', 0);
+
+    return bindings;
+}
+
+function computeDataBindings(gd, args) {
+    var traces, astr, val, aobj;
+    var bindings = [];
+
+    // Logic copied from Plotly.restyle:
+    astr = args[0];
+    val = args[1];
+    traces = args[2];
+    aobj = {};
+    if(typeof astr === 'string') {
+        aobj[astr] = val;
+    } else if(Lib.isPlainObject(astr)) {
+        // the 3-arg form
+        aobj = astr;
+
+        if(traces === undefined) {
+            traces = val;
+        }
+    } else {
+        return bindings;
+    }
+
+    if(traces === undefined) {
+        // Explicitly assign this to null instead of undefined:
+        traces = null;
+    }
+
+    crawl(aobj, function(path, attrName, attr) {
+        var thisTraces;
+        if(Array.isArray(attr)) {
+            var nAttr = Math.min(attr.length, gd.data.length);
+            if(traces) {
+                nAttr = Math.min(nAttr, traces.length);
+            }
+            thisTraces = [];
+            for(var j = 0; j < nAttr; j++) {
+                thisTraces[j] = traces ? traces[j] : j;
+            }
+        } else {
+            thisTraces = traces ? traces.slice(0) : null;
+        }
+
+        // Convert [7] to just 7 when traces is null:
+        if(thisTraces === null) {
+            if(Array.isArray(attr)) {
+                attr = attr[0];
+            }
+        } else if(Array.isArray(thisTraces)) {
+            if(!Array.isArray(attr)) {
+                var tmp = attr;
+                attr = [];
+                for(var i = 0; i < thisTraces.length; i++) {
+                    attr[i] = tmp;
+                }
+            }
+            attr.length = Math.min(thisTraces.length, attr.length);
+        }
+
+        bindings.push({
+            type: 'data',
+            prop: path,
+            traces: thisTraces,
+            value: attr
+        });
+    }, '', 0);
+
+    return bindings;
+}
+
+function crawl(attrs, callback, path, depth) {
+    Object.keys(attrs).forEach(function(attrName) {
+        var attr = attrs[attrName];
+
+        if(attrName[0] === '_') return;
+
+        var thisPath = path + (depth > 0 ? '.' : '') + attrName;
+
+        if(Lib.isPlainObject(attr)) {
+            crawl(attr, callback, thisPath, depth + 1);
+        } else {
+            // Only execute the callback on leaf nodes:
+            callback(thisPath, attrName, attr);
+        }
+    });
+}
diff --git a/src/plots/plots.js b/src/plots/plots.js
index 56aa8a9b696..5232397292b 100644
--- a/src/plots/plots.js
+++ b/src/plots/plots.js
@@ -38,6 +38,12 @@ var transformsRegistry = plots.transformsRegistry;
 
 var ErrorBars = require('../components/errorbars');
 
+var commandModule = require('./command');
+plots.executeAPICommand = commandModule.executeAPICommand;
+plots.computeAPICommandBindings = commandModule.computeAPICommandBindings;
+plots.manageCommandObserver = commandModule.manageCommandObserver;
+plots.hasSimpleAPICommandBindings = commandModule.hasSimpleAPICommandBindings;
+
 /**
  * Find subplot ids in data.
  * Meant to be used in the defaults step.
diff --git a/test/image/baselines/binding.png b/test/image/baselines/binding.png
new file mode 100644
index 00000000000..6c69d0ea29c
Binary files /dev/null and b/test/image/baselines/binding.png differ
diff --git a/test/image/mocks/binding.json b/test/image/mocks/binding.json
new file mode 100644
index 00000000000..510090bd6bf
--- /dev/null
+++ b/test/image/mocks/binding.json
@@ -0,0 +1,110 @@
+{
+  "data": [
+    {
+      "x": [0, 1, 2],
+      "y": [0.5, 1, 2.5]
+    }
+  ],
+  "layout": {
+    "sliders": [{
+      "active": 0,
+      "steps": [{
+        "label": "red",
+        "method": "restyle",
+        "args": [{"marker.color": "red"}]
+      }, {
+        "label": "orange",
+        "method": "restyle",
+        "args": [{"marker.color": "orange"}]
+      }, {
+        "label": "yellow",
+        "method": "restyle",
+        "args": [{"marker.color": "yellow"}]
+      }, {
+        "label": "green",
+        "method": "restyle",
+        "args": [{"marker.color": "green"}]
+      }, {
+        "label": "blue",
+        "method": "restyle",
+        "args": [{"marker.color": "blue"}]
+      }, {
+        "label": "purple",
+        "method": "restyle",
+        "args": [{"marker.color": "purple"}]
+      }],
+      "visible": true,
+      "x": 0,
+      "len": 1.0,
+      "xanchor": "left",
+      "y": 0,
+      "yanchor": "top",
+      "currentvalue": {
+        "visible": false
+      },
+
+      "transition": {
+        "duration": 150,
+        "easing": "cubic-in-out"
+      },
+
+      "pad": {
+        "r": 20,
+        "t": 40
+      },
+
+      "font": {}
+    }],
+    "updatemenus": [{
+      "active": 0,
+      "type": "buttons",
+      "buttons": [{
+        "label": "red",
+        "method": "restyle",
+        "args": [{"marker.color": "red"}]
+      }, {
+        "label": "orange",
+        "method": "restyle",
+        "args": [{"marker.color": "orange"}]
+      }, {
+        "label": "yellow",
+        "method": "restyle",
+        "args": [{"marker.color": "yellow"}]
+      }, {
+        "label": "green",
+        "method": "restyle",
+        "args": [{"marker.color": "green"}]
+      }, {
+        "label": "blue",
+        "method": "restyle",
+        "args": [{"marker.color": "blue"}]
+      }, {
+        "label": "purple",
+        "method": "restyle",
+        "args": [{"marker.color": "purple"}]
+      }],
+      "visible": true,
+      "direction": "right",
+      "x": 0,
+      "xanchor": "left",
+      "y": 1.05,
+      "yanchor": "bottom",
+      "pad": {
+        "l": 20,
+        "t": 20
+      }
+    }],
+    "xaxis": {
+      "range": [0, 2],
+      "autorange": true
+    },
+    "yaxis": {
+      "type": "linear",
+      "range": [0, 3],
+      "autorange": true
+    },
+    "height": 450,
+    "width": 1100,
+    "autosize": true
+  }
+}
diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js
new file mode 100644
index 00000000000..b88e2613c5a
--- /dev/null
+++ b/test/jasmine/tests/command_test.js
@@ -0,0 +1,641 @@
+var Plotly = require('@lib/index');
+var PlotlyInternal = require('@src/plotly');
+var Plots = Plotly.Plots;
+var createGraphDiv = require('../assets/create_graph_div');
+var destroyGraphDiv = require('../assets/destroy_graph_div');
+var fail = require('../assets/fail_test');
+var Lib = require('@src/lib');
+
+describe('Plots.executeAPICommand', function() {
+    'use strict';
+
+    var gd;
+
+    beforeEach(function() {
+        gd = createGraphDiv();
+    });
+
+    afterEach(function() {
+        destroyGraphDiv(gd);
+    });
+
+    describe('with a successful API command', function() {
+        beforeEach(function() {
+            spyOn(PlotlyInternal, 'restyle').and.callFake(function() {
+                return Promise.resolve('resolution');
+            });
+        });
+
+        it('calls the API method and resolves', function(done) {
+            Plots.executeAPICommand(gd, 'restyle', ['foo', 'bar']).then(function(value) {
+                var m = PlotlyInternal.restyle;
+                expect(m).toHaveBeenCalled();
+                expect(m.calls.count()).toEqual(1);
+                expect(m.calls.argsFor(0)).toEqual([gd, 'foo', 'bar']);
+
+                expect(value).toEqual('resolution');
+            }).catch(fail).then(done);
+        });
+
+    });
+
+    describe('with an unsuccessful command', function() {
+        beforeEach(function() {
+            spyOn(PlotlyInternal, 'restyle').and.callFake(function() {
+                return Promise.reject('rejection');
+            });
+        });
+
+        it('calls the API method and rejects', function(done) {
+            Plots.executeAPICommand(gd, 'restyle', ['foo', 'bar']).then(fail, function(value) {
+                var m = PlotlyInternal.restyle;
+                expect(m).toHaveBeenCalled();
+                expect(m.calls.count()).toEqual(1);
+                expect(m.calls.argsFor(0)).toEqual([gd, 'foo', 'bar']);
+
+                expect(value).toEqual('rejection');
+            }).catch(fail).then(done);
+        });
+
+    });
+});
+
+describe('Plots.hasSimpleAPICommandBindings', function() {
+    'use strict';
+    var gd;
+    beforeEach(function() {
+        gd = createGraphDiv();
+
+        Plotly.plot(gd, [
+            {x: [1, 2, 3], y: [1, 2, 3]},
+            {x: [1, 2, 3], y: [4, 5, 6]},
+        ]);
+    });
+
+    afterEach(function() {
+        destroyGraphDiv(gd);
+    });
+
+    it('return the binding when bindings are simple', function() {
+        var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{
+            method: 'restyle',
+            args: [{'marker.size': 10}]
+        }, {
+            method: 'restyle',
+            args: [{'marker.size': 20}]
+        }]);
+
+        expect(isSimple).toEqual({
+            type: 'data',
+            prop: 'marker.size',
+            traces: null,
+            value: 10
+        });
+    });
+
+    it('return false when properties are not the same', function() {
+        var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{
+            method: 'restyle',
+            args: [{'marker.size': 10}]
+        }, {
+            method: 'restyle',
+            args: [{'marker.color': 20}]
+        }]);
+
+        expect(isSimple).toBe(false);
+    });
+
+    it('return false when a command binds to more than one property', function() {
+        var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{
+            method: 'restyle',
+            args: [{'marker.color': 10, 'marker.size': 12}]
+        }, {
+            method: 'restyle',
+            args: [{'marker.color': 20}]
+        }]);
+
+        expect(isSimple).toBe(false);
+    });
+
+    it('return false when commands affect different traces', function() {
+        var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{
+            method: 'restyle',
+            args: [{'marker.color': 10}, [0]]
+        }, {
+            method: 'restyle',
+            args: [{'marker.color': 20}, [1]]
+        }]);
+
+        expect(isSimple).toBe(false);
+    });
+
+    it('return the binding when commands affect the same traces', function() {
+        var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{
+            method: 'restyle',
+            args: [{'marker.color': 10}, [1]]
+        }, {
+            method: 'restyle',
+            args: [{'marker.color': 20}, [1]]
+        }]);
+
+        expect(isSimple).toEqual({
+            type: 'data',
+            prop: 'marker.color',
+            traces: [ 1 ],
+            value: [ 10 ]
+        });
+    });
+
+    it('return the binding when commands affect the same traces in different order', function() {
+        var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{
+            method: 'restyle',
+            args: [{'marker.color': 10}, [1, 2]]
+        }, {
+            method: 'restyle',
+            args: [{'marker.color': 20}, [2, 1]]
+        }]);
+
+        expect(isSimple).toEqual({
+            type: 'data',
+            prop: 'marker.color',
+            traces: [ 1, 2 ],
+            value: [ 10, 10 ]
+        });
+    });
+});
+
+describe('Plots.computeAPICommandBindings', function() {
+    'use strict';
+
+    var gd;
+
+    beforeEach(function() {
+        gd = createGraphDiv();
+
+        Plotly.plot(gd, [
+            {x: [1, 2, 3], y: [1, 2, 3]},
+            {x: [1, 2, 3], y: [4, 5, 6]},
+        ]);
+    });
+
+    afterEach(function() {
+        destroyGraphDiv(gd);
+    });
+
+    describe('restyle', function() {
+        describe('with invalid notation', function() {
+            it('with a scalar value', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'restyle', [['x']]);
+                expect(result).toEqual([]);
+            });
+        });
+
+        describe('with astr + val notation', function() {
+            describe('and a single attribute', function() {
+                it('with a scalar value', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: null, type: 'data', value: 7}]);
+                });
+
+                it('with an array value and no trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]);
+                });
+
+                it('with trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]);
+                });
+
+                it('with a different trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]);
+                });
+
+                it('with an array value', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7], [1]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [1], type: 'data', value: [7]}]);
+                });
+
+                it('with two array values and two traces specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [0, 1]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [0, 1], type: 'data', value: [7, 5]}]);
+                });
+
+                it('with traces specified in reverse order', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1, 0]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [1, 0], type: 'data', value: [7, 5]}]);
+                });
+
+                it('with two values and a single trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [0]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]);
+                });
+
+                it('with two values and a different trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1]]);
+                    expect(result).toEqual([{prop: 'marker.size', traces: [1], type: 'data', value: [7]}]);
+                });
+            });
+        });
+
+
+        describe('with aobj notation', function() {
+            describe('and a single attribute', function() {
+                it('with a scalar value', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: null, value: 7}]);
+                });
+
+                it('with trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [0]]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0], value: [7]}]);
+                });
+
+                it('with a different trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [1]]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1], value: [7]}]);
+                });
+
+                it('with an array value', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7]}, [1]]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1], value: [7]}]);
+                });
+
+                it('with two array values and two traces specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [0, 1]]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0, 1], value: [7, 5]}]);
+                });
+
+                it('with traces specified in reverse order', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1, 0]]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1, 0], value: [7, 5]}]);
+                });
+
+                it('with two values and a single trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [0]]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0], value: [7]}]);
+                });
+
+                it('with two values and a different trace specified', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1]]);
+                    expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1], value: [7]}]);
+                });
+            });
+
+            describe('and multiple attributes', function() {
+                it('with a scalar value', function() {
+                    var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7, 'text.color': 'blue'}]);
+                    expect(result).toEqual([
+                        {type: 'data', prop: 'marker.size', traces: null, value: 7},
+                        {type: 'data', prop: 'text.color', traces: null, value: 'blue'}
+                    ]);
+                });
+            });
+        });
+
+        describe('with mixed notation', function() {
+            it('and nested object and nested attr', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'restyle', [{
+                    y: [[3, 4, 5]],
+                    'marker.size': [10, 20, 25],
+                    'line.color': 'red',
+                    line: {
+                        width: [2, 8]
+                    }
+                }]);
+
+                // The results are definitely not completely intuitive, so this
+                // is based upon empirical results with a codepen example:
+                expect(result).toEqual([
+                    {type: 'data', prop: 'y', traces: [0], value: [[3, 4, 5]]},
+                    {type: 'data', prop: 'marker.size', traces: [0, 1], value: [10, 20]},
+                    {type: 'data', prop: 'line.color', traces: null, value: 'red'},
+                    {type: 'data', prop: 'line.width', traces: [0, 1], value: [2, 8]}
+                ]);
+            });
+
+            it('and traces specified', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'restyle', [{
+                    y: [[3, 4, 5]],
+                    'marker.size': [10, 20, 25],
+                    'line.color': 'red',
+                    line: {
+                        width: [2, 8]
+                    }
+                }, [1, 0]]);
+
+                expect(result).toEqual([
+                    {type: 'data', prop: 'y', traces: [1], value: [[3, 4, 5]]},
+                    {type: 'data', prop: 'marker.size', traces: [1, 0], value: [10, 20]},
+
+                    // This result is actually not quite correct. Setting `line` should override
+                    // this—or actually it's technically undefined since the iteration order of
+                    // objects is not strictly defined but is at least consistent across browsers.
+                    // The worst-case scenario right now isn't too bad though since it's an obscure
+                    // case that will definitely cause bailout anyway before any bindings would
+                    // happen.
+                    {type: 'data', prop: 'line.color', traces: [1, 0], value: ['red', 'red']},
+
+                    {type: 'data', prop: 'line.width', traces: [1, 0], value: [2, 8]}
+                ]);
+            });
+
+            it('and more data than traces', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'restyle', [{
+                    y: [[3, 4, 5]],
+                    'marker.size': [10, 20, 25],
+                    'line.color': 'red',
+                    line: {
+                        width: [2, 8]
+                    }
+                }, [1]]);
+
+                expect(result).toEqual([
+                    {type: 'data', prop: 'y', traces: [1], value: [[3, 4, 5]]},
+                    {type: 'data', prop: 'marker.size', traces: [1], value: [10]},
+                    {type: 'data', prop: 'line.color', traces: [1], value: ['red']},
+                    {type: 'data', prop: 'line.width', traces: [1], value: [2]}
+                ]);
+            });
+        });
+    });
+
+    describe('relayout', function() {
+        describe('with invalid notation', function() {
+            it('and a scalar value', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'relayout', [['x']]);
+                expect(result).toEqual([]);
+            });
+        });
+
+        describe('with aobj notation', function() {
+            it('and a single attribute', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500}]);
+                expect(result).toEqual([{type: 'layout', prop: 'height', value: 500}]);
+            });
+
+            it('and two attributes', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500, width: 100}]);
+                expect(result).toEqual([{type: 'layout', prop: 'height', value: 500}, {type: 'layout', prop: 'width', value: 100}]);
+            });
+        });
+
+        describe('with astr + val notation', function() {
+            it('and an attribute', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'relayout', ['width', 100]);
+                expect(result).toEqual([{type: 'layout', prop: 'width', value: 100}]);
+            });
+
+            it('and nested atributes', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'relayout', ['margin.l', 10]);
+                expect(result).toEqual([{type: 'layout', prop: 'margin.l', value: 10}]);
+            });
+        });
+
+        describe('with mixed notation', function() {
+            it('containing aob + astr', function() {
+                var result = Plots.computeAPICommandBindings(gd, 'relayout', [{
+                    'width': 100,
+                    'margin.l': 10
+                }]);
+                expect(result).toEqual([
+                    {type: 'layout', prop: 'width', value: 100},
+                    {type: 'layout', prop: 'margin.l', value: 10}
+                ]);
+            });
+        });
+    });
+
+    describe('update', function() {
+        it('computes bindings', function() {
+            var result = Plots.computeAPICommandBindings(gd, 'update', [{
+                y: [[3, 4, 5]],
+                'marker.size': [10, 20, 25],
+                'line.color': 'red',
+                line: {
+                    width: [2, 8]
+                }
+            }, {
+                'margin.l': 50,
+                width: 10
+            }, [1]]);
+
+            expect(result).toEqual([
+                {type: 'data', prop: 'y', traces: [1], value: [[3, 4, 5]]},
+                {type: 'data', prop: 'marker.size', traces: [1], value: [10]},
+                {type: 'data', prop: 'line.color', traces: [1], value: ['red']},
+                {type: 'data', prop: 'line.width', traces: [1], value: [2]},
+                {type: 'layout', prop: 'margin.l', value: 50},
+                {type: 'layout', prop: 'width', value: 10}
+            ]);
+        });
+    });
+
+    describe('animate', function() {
+        it('binds to the frame for a simple animate command', function() {
+            var result = Plots.computeAPICommandBindings(gd, 'animate', [['framename']]);
+
+            expect(result).toEqual([{type: 'layout', prop: '_currentFrame', value: 'framename'}]);
+        });
+
+        it('binds to nothing for a multi-frame animate command', function() {
+            var result = Plots.computeAPICommandBindings(gd, 'animate', [['frame1', 'frame2']]);
+
+            expect(result).toEqual([]);
+        });
+    });
+});
+
+describe('component bindings', function() {
+    'use strict';
+
+    var gd;
+    var mock = require('@mocks/binding.json');
+
+    beforeEach(function(done) {
+        var mockCopy = Lib.extendDeep({}, mock);
+        gd = createGraphDiv();
+
+        Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done);
+    });
+
+    afterEach(function() {
+        destroyGraphDiv(gd);
+    });
+
+    it('creates an observer', function(done) {
+        var count = 0;
+        Plots.manageCommandObserver(gd, {}, [
+            { method: 'restyle', args: ['marker.color', 'red'] },
+            { method: 'restyle', args: ['marker.color', 'green'] }
+        ], function(data) {
+            count++;
+            expect(data.index).toEqual(1);
+        });
+
+        // Doesn't trigger the callback:
+        Plotly.relayout(gd, 'width', 400).then(function() {
+            // Triggers the callback:
+            return Plotly.restyle(gd, 'marker.color', 'green');
+        }).then(function() {
+            // Doesn't trigger a callback:
+            return Plotly.restyle(gd, 'marker.width', 8);
+        }).then(function() {
+            expect(count).toEqual(1);
+        }).catch(fail).then(done);
+    });
+
+    it('logs a warning if unable to create an observer', function() {
+        var warnings = 0;
+        spyOn(Lib, 'warn').and.callFake(function() {
+            warnings++;
+        });
+
+        Plots.manageCommandObserver(gd, {}, [
+            { method: 'restyle', args: ['marker.color', 'red'] },
+            { method: 'restyle', args: [{'line.color': 'green', 'marker.color': 'green'}] }
+        ]);
+
+        expect(warnings).toEqual(1);
+    });
+
+    it('udpates bound components when the value changes', function(done) {
+        expect(gd.layout.sliders[0].active).toBe(0);
+
+        Plotly.restyle(gd, 'marker.color', 'blue').then(function() {
+            expect(gd.layout.sliders[0].active).toBe(4);
+        }).catch(fail).then(done);
+    });
+
+    it('udpates bound components when the computed value changes', function(done) {
+        expect(gd.layout.sliders[0].active).toBe(0);
+
+        // The default line color comes from the marker color, if specified.
+        // That is, the fact that the marker color changes is just incidental, but
+        // nonetheless is bound by value to the component.
+        Plotly.restyle(gd, 'line.color', 'blue').then(function() {
+            expect(gd.layout.sliders[0].active).toBe(4);
+        }).catch(fail).then(done);
+    });
+});
+
+describe('attaching component bindings', function() {
+    'use strict';
+    var gd;
+
+    beforeEach(function(done) {
+        gd = createGraphDiv();
+        Plotly.plot(gd, [{x: [1, 2, 3], y: [1, 2, 3]}]).then(done);
+    });
+
+    afterEach(function() {
+        destroyGraphDiv(gd);
+    });
+
+    it('attaches and updates bindings for sliders', function(done) {
+        expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined();
+
+        Plotly.relayout(gd, {
+            sliders: [{
+                // This one gets bindings:
+                steps: [
+                    {label: 'first', method: 'restyle', args: ['marker.color', 'red']},
+                    {label: 'second', method: 'restyle', args: ['marker.color', 'blue']},
+                ]
+            }, {
+                // This one does *not*:
+                steps: [
+                    {label: 'first', method: 'restyle', args: ['line.color', 'red']},
+                    {label: 'second', method: 'restyle', args: ['marker.color', 'blue']},
+                ]
+            }]
+        }).then(function() {
+            // Check that it has attached a listener:
+            expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function');
+
+            // Confirm the first position is selected:
+            expect(gd.layout.sliders[0].active).toBeUndefined();
+
+            // Modify the plot
+            return Plotly.restyle(gd, {'marker.color': 'blue'});
+        }).then(function() {
+            // Confirm that this has changed the slider position:
+            expect(gd.layout.sliders[0].active).toBe(1);
+
+            // Swap the values of the components:
+            return Plotly.relayout(gd, {
+                'sliders[0].steps[0].args[1]': 'green',
+                'sliders[0].steps[1].args[1]': 'red'
+            });
+        }).then(function() {
+            return Plotly.restyle(gd, {'marker.color': 'green'});
+        }).then(function() {
+            // Confirm that the lookup table has been updated:
+            expect(gd.layout.sliders[0].active).toBe(0);
+
+            // Check that it still has one attached listener:
+            expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function');
+
+            // Change this to a non-simple binding:
+            return Plotly.relayout(gd, {'sliders[0].steps[0].args[0]': 'line.color'});
+        }).then(function() {
+            // Bindings are no longer simple, so check to ensure they have
+            // been removed
+            expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined();
+        }).catch(fail).then(done);
+    });
+
+    it('attaches and updates bindings for updatemenus', function(done) {
+        expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined();
+
+        Plotly.relayout(gd, {
+            updatemenus: [{
+                // This one gets bindings:
+                buttons: [
+                    {label: 'first', method: 'restyle', args: ['marker.color', 'red']},
+                    {label: 'second', method: 'restyle', args: ['marker.color', 'blue']},
+                ]
+            }, {
+                // This one does *not*:
+                buttons: [
+                    {label: 'first', method: 'restyle', args: ['line.color', 'red']},
+                    {label: 'second', method: 'restyle', args: ['marker.color', 'blue']},
+                ]
+            }]
+        }).then(function() {
+            // Check that it has attached a listener:
+            expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function');
+
+            // Confirm the first position is selected:
+            expect(gd.layout.updatemenus[0].active).toBeUndefined();
+
+            // Modify the plot
+            return Plotly.restyle(gd, {'marker.color': 'blue'});
+        }).then(function() {
+            // Confirm that this has changed the slider position:
+            expect(gd.layout.updatemenus[0].active).toBe(1);
+
+            // Swap the values of the components:
+            return Plotly.relayout(gd, {
+                'updatemenus[0].buttons[0].args[1]': 'green',
+                'updatemenus[0].buttons[1].args[1]': 'red'
+            });
+        }).then(function() {
+            return Plotly.restyle(gd, {'marker.color': 'green'});
+        }).then(function() {
+            // Confirm that the lookup table has been updated:
+            expect(gd.layout.updatemenus[0].active).toBe(0);
+
+            // Check that it still has one attached listener:
+            expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function');
+
+            // Change this to a non-simple binding:
+            return Plotly.relayout(gd, {'updatemenus[0].buttons[0].args[0]': 'line.color'});
+        }).then(function() {
+            // Bindings are no longer simple, so check to ensure they have
+            // been removed
+            expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined();
+        }).catch(fail).then(done);
+    });
+});
diff --git a/test/jasmine/tests/sliders_test.js b/test/jasmine/tests/sliders_test.js
index f23a6926f4e..58b328c3272 100644
--- a/test/jasmine/tests/sliders_test.js
+++ b/test/jasmine/tests/sliders_test.js
@@ -182,7 +182,34 @@ describe('sliders defaults', function() {
     });
 });
 
-describe('update sliders interactions', function() {
+describe('sliders initialization', function() {
+    'use strict';
+    var gd;
+
+    beforeEach(function(done) {
+        gd = createGraphDiv();
+
+        Plotly.plot(gd, [{x: [1, 2, 3]}], {
+            sliders: [{
+                steps: [
+                    {method: 'restyle', args: [], label: 'first'},
+                    {method: 'restyle', args: [], label: 'second'},
+                ]
+            }]
+        }).then(done);
+    });
+
+    afterEach(function() {
+        Plotly.purge(gd);
+        destroyGraphDiv();
+    });
+
+    it('does not set active on initial plot', function() {
+        expect(gd.layout.sliders[0].active).toBeUndefined();
+    });
+});
+
+describe('sliders interactions', function() {
     'use strict';
 
     var mock = require('@mocks/sliders.json');
diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js
index f3e1e95a06e..e7d22f73d29 100644
--- a/test/jasmine/tests/updatemenus_test.js
+++ b/test/jasmine/tests/updatemenus_test.js
@@ -225,6 +225,33 @@ describe('update menus buttons', function() {
     }
 });
 
+describe('update menus initialization', function() {
+    'use strict';
+    var gd;
+
+    beforeEach(function(done) {
+        gd = createGraphDiv();
+
+        Plotly.plot(gd, [{x: [1, 2, 3]}], {
+            updatemenus: [{
+                buttons: [
+                    {method: 'restyle', args: [], label: 'first'},
+                    {method: 'restyle', args: [], label: 'second'},
+                ]
+            }]
+        }).then(done);
+    });
+
+    afterEach(function() {
+        Plotly.purge(gd);
+        destroyGraphDiv();
+    });
+
+    it('does not set active on initial plot', function() {
+        expect(gd.layout.updatemenus[0].active).toBeUndefined();
+    });
+});
+
 describe('update menus interactions', function() {
     'use strict';