diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module
index a465e4767adf8c125e13159fda72c53f64685322..47f521fbb25c248f926a37015ddde7bc5af386c8 100644
--- a/core/modules/contextual/contextual.module
+++ b/core/modules/contextual/contextual.module
@@ -94,14 +94,26 @@ function contextual_permission() {
  */
 function contextual_library_info() {
   $path = drupal_get_path('module', 'contextual');
+  // Add the JavaScript, with a group and weight such that it will run
+  // before core/modules/contextual/js/contextual.toolbar.js.
+  $options = array(
+    'group' => JS_LIBRARY,
+    'weight' => -2,
+  );
   $libraries['drupal.contextual-links'] = array(
     'title' => 'Contextual Links',
     'website' => 'http://drupal.org/node/473268',
     'version' => \Drupal::VERSION,
     'js' => array(
-      // Add the JavaScript, with a group and weight such that it will run
-      // before modules/contextual/js/contextual.toolbar.js.
-      $path . '/js/contextual.js' => array('group' => JS_LIBRARY, 'weight' => -2),
+      // Core.
+      $path . '/js/contextual.js' => $options,
+      // Models.
+      $path . '/js/models/StateModel.js' => $options,
+      // Views.
+      $path . '/js/views/AuralView.js' => $options,
+      $path . '/js/views/KeyboardView.js' => $options,
+      $path . '/js/views/RegionView.js' => $options,
+      $path . '/js/views/VisualView.js' => $options,
     ),
     'css' => array(
       $path . '/css/contextual.module.css' => array(),
diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js
index 46164113362d8c1cf10f7f951ff9979cb2f663bc..0b0561e02355f2ab7854d6f35826beba0153d875 100644
--- a/core/modules/contextual/js/contextual.js
+++ b/core/modules/contextual/js/contextual.js
@@ -3,7 +3,7 @@
  * Attaches behaviors for the Contextual module.
  */
 
-(function ($, Drupal, drupalSettings, Backbone, Modernizr) {
+(function ($, Drupal, drupalSettings, Backbone) {
 
 "use strict";
 
@@ -36,7 +36,7 @@ function initContextual ($contextual) {
     .prepend(Drupal.theme('contextualTrigger'));
 
   // Create a model and the appropriate views.
-  var model = new contextual.Model({
+  var model = new contextual.StateModel({
     title: $region.find('h2:first').text().trim()
   });
   var viewOptions = $.extend({ el: $contextual, model: model }, options);
@@ -160,9 +160,6 @@ Drupal.behaviors.contextual = {
   }
 };
 
-/**
- * Model and View definitions.
- */
 Drupal.contextual = {
   // The Drupal.contextual.View instances associated with each list element of
   // contextual links.
@@ -170,253 +167,11 @@ Drupal.contextual = {
 
   // The Drupal.contextual.RegionView instances associated with each contextual
   // region element.
-  regionViews: [],
-
-  /**
-   * Models the state of a contextual link's trigger and list.
-   */
-  Model: Backbone.Model.extend({
-    defaults: {
-      // The title of the entity to which these contextual links apply.
-      title: '',
-      // Represents if the contextual region is being hovered.
-      regionIsHovered: false,
-      // Represents if the contextual trigger or options have focus.
-      hasFocus: false,
-      // Represents if the contextual options for an entity are available to
-      // be selected.
-      isOpen: false,
-      // When the model is locked, the trigger remains active.
-      isLocked: false
-    },
-
-    /**
-     * Opens or closes the contextual link.
-     *
-     * If it is opened, then also give focus.
-     */
-    toggleOpen: function () {
-      var newIsOpen = !this.get('isOpen');
-      this.set('isOpen', newIsOpen);
-      if (newIsOpen) {
-        this.focus();
-      }
-      return this;
-    },
-
-    /**
-     * Closes this contextual link.
-     *
-     * Does not call blur() because we want to allow a contextual link to have
-     * focus, yet be closed for example when hovering.
-     */
-    close: function () {
-      this.set('isOpen', false);
-      return this;
-    },
-
-    /**
-     * Gives focus to this contextual link.
-     *
-     * Also closes + removes focus from every other contextual link.
-     */
-    focus: function () {
-      this.set('hasFocus', true);
-      var cid = this.cid;
-      this.collection.each(function (model) {
-        if (model.cid !== cid) {
-          model.close().blur();
-        }
-      });
-      return this;
-    },
-
-    /**
-     * Removes focus from this contextual link, unless it is open.
-     */
-    blur: function () {
-      if (!this.get('isOpen')) {
-        this.set('hasFocus', false);
-      }
-      return this;
-    }
-  }),
-
-  /**
-   * Renders the visual view of a contextual link. Listens to mouse & touch.
-   */
-  VisualView: Backbone.View.extend({
-    events: function () {
-      // Prevents delay and simulated mouse events.
-      var touchEndToClick = function (event) {
-        event.preventDefault();
-        event.target.click();
-      };
-      var mapping = {
-        'click .trigger': function () { this.model.toggleOpen(); },
-        'touchend .trigger': touchEndToClick,
-        'click .contextual-links a': function () { this.model.close().blur(); },
-        'touchend .contextual-links a': touchEndToClick
-      };
-      // We only want mouse hover events on non-touch.
-      if (!Modernizr.touch) {
-        mapping.mouseenter =  function () { this.model.focus(); };
-      }
-      return mapping;
-    },
-
-    /**
-     * {@inheritdoc}
-     */
-    initialize: function () {
-      this.listenTo(this.model, 'change', this.render);
-    },
-
-    /**
-     * {@inheritdoc}
-     */
-    render: function () {
-      var isOpen = this.model.get('isOpen');
-      // The trigger should be visible when:
-      //  - the mouse hovered over the region,
-      //  - the trigger is locked,
-      //  - and for as long as the contextual menu is open.
-      var isVisible = this.model.get('isLocked') || this.model.get('regionIsHovered') || isOpen;
-
-      this.$el
-        // The open state determines if the links are visible.
-        .toggleClass('open', isOpen)
-        // Update the visibility of the trigger.
-        .find('.trigger').toggleClass('visually-hidden', !isVisible);
-
-      // Nested contextual region handling: hide any nested contextual triggers.
-      if ('isOpen' in this.model.changed) {
-        this.$el.closest('.contextual-region')
-          .find('.contextual .trigger:not(:first)')
-          .toggle(!isOpen);
-      }
-
-      return this;
-    }
-  }),
-
-  /**
-   * Renders the aural view of a contextual link (i.e. screen reader support).
-   */
-  AuralView: Backbone.View.extend({
-    /**
-     * {@inheritdoc}
-     */
-    initialize: function (options) {
-      this.options = options;
-
-      this.listenTo(this.model, 'change', this.render);
-
-      // Use aria-role form so that the number of items in the list is spoken.
-      this.$el.attr('role', 'form');
-
-      // Initial render.
-      this.render();
-    },
-
-    /**
-     * {@inheritdoc}
-     */
-    render: function () {
-      var isOpen = this.model.get('isOpen');
-
-      // Set the hidden property of the links.
-      this.$el.find('.contextual-links')
-        .prop('hidden', !isOpen);
-
-      // Update the view of the trigger.
-      this.$el.find('.trigger')
-        .text(Drupal.t('@action @title configuration options', {
-          '@action': (!isOpen) ? this.options.strings.open : this.options.strings.close,
-          '@title': this.model.get('title')
-        }))
-        .attr('aria-pressed', isOpen);
-    }
-  }),
-
-  /**
-   * Listens to keyboard.
-   */
-  KeyboardView: Backbone.View.extend({
-    events: {
-      'focus .trigger': 'focus',
-      'focus .contextual-links a': 'focus',
-      'blur .trigger': function () { this.model.blur(); },
-      'blur .contextual-links a': function () {
-        // Set up a timeout to allow a user to tab between the trigger and the
-        // contextual links without the menu dismissing.
-        var that = this;
-        this.timer = window.setTimeout(function () {
-          that.model.close().blur();
-        }, 150);
-      }
-    },
-
-    /**
-     * {@inheritdoc}
-     */
-    initialize: function () {
-      // The timer is used to create a delay before dismissing the contextual
-      // links on blur. This is only necessary when keyboard users tab into
-      // contextual links without edit mode (i.e. without TabbingManager).
-      // That means that if we decide to disable tabbing of contextual links
-      // without edit mode, all this timer logic can go away.
-      this.timer = NaN;
-    },
-
-    /**
-     * Sets focus on the model; Clears the timer that dismisses the links.
-     */
-    focus: function () {
-      // Clear the timeout that might have been set by blurring a link.
-      window.clearTimeout(this.timer);
-      this.model.focus();
-    }
-  }),
-
-  /**
-   * Renders the visual view of a contextual region element.
-   */
-  RegionView: Backbone.View.extend({
-    events: function () {
-      var mapping = {
-        mouseenter: function () { this.model.set('regionIsHovered', true); },
-        mouseleave: function () {
-          this.model.close().blur().set('regionIsHovered', false);
-        }
-      };
-      // We don't want mouse hover events on touch.
-      if (Modernizr.touch) {
-        mapping = {};
-      }
-      return mapping;
-    },
-
-    /**
-     * {@inheritdoc}
-     */
-    initialize: function () {
-      this.listenTo(this.model, 'change:hasFocus', this.render);
-    },
-
-    /**
-     * {@inheritdoc}
-     */
-    render: function () {
-      this.$el.toggleClass('focus', this.model.get('hasFocus'));
-
-      return this;
-    }
-  })
+  regionViews: []
 };
 
-// A Backbone.Collection of Drupal.contextual.Model instances.
-Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.Model });
+// A Backbone.Collection of Drupal.contextual.StateModel instances.
+Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.StateModel });
 
 /**
  * A trigger is an interactive element often bound to a click handler.
@@ -428,4 +183,4 @@ Drupal.theme.contextualTrigger = function () {
   return '<button class="trigger visually-hidden focusable" type="button"></button>';
 };
 
-})(jQuery, Drupal, drupalSettings, Backbone, Modernizr);
+})(jQuery, Drupal, drupalSettings, Backbone);
diff --git a/core/modules/contextual/js/models/StateModel.js b/core/modules/contextual/js/models/StateModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..dc633f21e0c3df7cf8c66a508d640259482dfdb8
--- /dev/null
+++ b/core/modules/contextual/js/models/StateModel.js
@@ -0,0 +1,82 @@
+/**
+ * @file
+ * A Backbone Model for the state of a contextual link's trigger, list & region.
+ */
+
+(function (Drupal, Backbone) {
+
+"use strict";
+
+/**
+ * Models the state of a contextual link's trigger, list & region.
+ */
+Drupal.contextual.StateModel = Backbone.Model.extend({
+
+  defaults: {
+    // The title of the entity to which these contextual links apply.
+    title: '',
+    // Represents if the contextual region is being hovered.
+    regionIsHovered: false,
+    // Represents if the contextual trigger or options have focus.
+    hasFocus: false,
+    // Represents if the contextual options for an entity are available to
+    // be selected (i.e. whether the list of options is visible).
+    isOpen: false,
+    // When the model is locked, the trigger remains active.
+    isLocked: false
+  },
+
+  /**
+   * Opens or closes the contextual link.
+   *
+   * If it is opened, then also give focus.
+   */
+  toggleOpen: function () {
+    var newIsOpen = !this.get('isOpen');
+    this.set('isOpen', newIsOpen);
+    if (newIsOpen) {
+      this.focus();
+    }
+    return this;
+  },
+
+  /**
+   * Closes this contextual link.
+   *
+   * Does not call blur() because we want to allow a contextual link to have
+   * focus, yet be closed for example when hovering.
+   */
+  close: function () {
+    this.set('isOpen', false);
+    return this;
+  },
+
+  /**
+   * Gives focus to this contextual link.
+   *
+   * Also closes + removes focus from every other contextual link.
+   */
+  focus: function () {
+    this.set('hasFocus', true);
+    var cid = this.cid;
+    this.collection.each(function (model) {
+      if (model.cid !== cid) {
+        model.close().blur();
+      }
+    });
+    return this;
+  },
+
+  /**
+   * Removes focus from this contextual link, unless it is open.
+   */
+  blur: function () {
+    if (!this.get('isOpen')) {
+      this.set('hasFocus', false);
+    }
+    return this;
+  }
+
+});
+
+})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/views/AuralView.js b/core/modules/contextual/js/views/AuralView.js
new file mode 100644
index 0000000000000000000000000000000000000000..9ce04c1af4eb8105659c051753973cd72cff3770
--- /dev/null
+++ b/core/modules/contextual/js/views/AuralView.js
@@ -0,0 +1,51 @@
+/**
+ * @file
+ * A Backbone View that provides the aural view of a contextual link.
+ */
+
+(function (Drupal, Backbone) {
+
+"use strict";
+
+/**
+ * Renders the aural view of a contextual link (i.e. screen reader support).
+ */
+Drupal.contextual.AuralView = Backbone.View.extend({
+
+  /**
+   * {@inheritdoc}
+   */
+  initialize: function (options) {
+    this.options = options;
+
+    this.listenTo(this.model, 'change', this.render);
+
+    // Use aria-role form so that the number of items in the list is spoken.
+    this.$el.attr('role', 'form');
+
+    // Initial render.
+    this.render();
+  },
+
+  /**
+   * {@inheritdoc}
+   */
+  render: function () {
+    var isOpen = this.model.get('isOpen');
+
+    // Set the hidden property of the links.
+    this.$el.find('.contextual-links')
+      .prop('hidden', !isOpen);
+
+    // Update the view of the trigger.
+    this.$el.find('.trigger')
+      .text(Drupal.t('@action @title configuration options', {
+        '@action': (!isOpen) ? this.options.strings.open : this.options.strings.close,
+        '@title': this.model.get('title')
+      }))
+      .attr('aria-pressed', isOpen);
+  }
+
+});
+
+})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/views/KeyboardView.js b/core/modules/contextual/js/views/KeyboardView.js
new file mode 100644
index 0000000000000000000000000000000000000000..ec55523a13f2d1f31d1027ef7809c5c4106edf22
--- /dev/null
+++ b/core/modules/contextual/js/views/KeyboardView.js
@@ -0,0 +1,51 @@
+/**
+ * @file
+ * A Backbone View that provides keyboard interaction for a contextual link.
+ */
+
+(function (Drupal, Backbone) {
+
+"use strict";
+
+/**
+ * Provides keyboard interaction for a contextual link.
+ */
+Drupal.contextual.KeyboardView = Backbone.View.extend({
+  events: {
+    'focus .trigger': 'focus',
+    'focus .contextual-links a': 'focus',
+    'blur .trigger': function () { this.model.blur(); },
+    'blur .contextual-links a': function () {
+      // Set up a timeout to allow a user to tab between the trigger and the
+      // contextual links without the menu dismissing.
+      var that = this;
+      this.timer = window.setTimeout(function () {
+        that.model.close().blur();
+      }, 150);
+    }
+  },
+
+  /**
+   * {@inheritdoc}
+   */
+  initialize: function () {
+    // The timer is used to create a delay before dismissing the contextual
+    // links on blur. This is only necessary when keyboard users tab into
+    // contextual links without edit mode (i.e. without TabbingManager).
+    // That means that if we decide to disable tabbing of contextual links
+    // without edit mode, all this timer logic can go away.
+    this.timer = NaN;
+  },
+
+  /**
+   * Sets focus on the model; Clears the timer that dismisses the links.
+   */
+  focus: function () {
+    // Clear the timeout that might have been set by blurring a link.
+    window.clearTimeout(this.timer);
+    this.model.focus();
+  }
+
+});
+
+})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/views/RegionView.js b/core/modules/contextual/js/views/RegionView.js
new file mode 100644
index 0000000000000000000000000000000000000000..c29ce47e455c0a53d6f7f711367b8187a6696caf
--- /dev/null
+++ b/core/modules/contextual/js/views/RegionView.js
@@ -0,0 +1,47 @@
+/**
+ * @file
+ * A Backbone View that renders the visual view of a contextual region element.
+ */
+
+(function (Drupal, Backbone, Modernizr) {
+
+"use strict";
+
+/**
+ * Renders the visual view of a contextual region element.
+ */
+Drupal.contextual.RegionView = Backbone.View.extend({
+
+  events: function () {
+    var mapping = {
+      mouseenter: function () { this.model.set('regionIsHovered', true); },
+      mouseleave: function () {
+        this.model.close().blur().set('regionIsHovered', false);
+      }
+    };
+    // We don't want mouse hover events on touch.
+    if (Modernizr.touch) {
+      mapping = {};
+    }
+    return mapping;
+  },
+
+  /**
+   * {@inheritdoc}
+   */
+  initialize: function () {
+    this.listenTo(this.model, 'change:hasFocus', this.render);
+  },
+
+  /**
+   * {@inheritdoc}
+   */
+  render: function () {
+    this.$el.toggleClass('focus', this.model.get('hasFocus'));
+
+    return this;
+  }
+
+});
+
+})(Drupal, Backbone, Modernizr);
diff --git a/core/modules/contextual/js/views/VisualView.js b/core/modules/contextual/js/views/VisualView.js
new file mode 100644
index 0000000000000000000000000000000000000000..3c9119b0bc383744b3274f0dc00c8aef592cc9f7
--- /dev/null
+++ b/core/modules/contextual/js/views/VisualView.js
@@ -0,0 +1,70 @@
+/**
+ * @file
+ * A Backbone View that provides the visual view of a contextual link.
+ */
+
+(function (Drupal, Backbone, Modernizr) {
+
+"use strict";
+
+/**
+ * Renders the visual view of a contextual link. Listens to mouse & touch.
+ */
+Drupal.contextual.VisualView = Backbone.View.extend({
+
+  events: function () {
+    // Prevents delay and simulated mouse events.
+    var touchEndToClick = function (event) {
+      event.preventDefault();
+      event.target.click();
+    };
+    var mapping = {
+      'click .trigger': function () { this.model.toggleOpen(); },
+      'touchend .trigger': touchEndToClick,
+      'click .contextual-links a': function () { this.model.close().blur(); },
+      'touchend .contextual-links a': touchEndToClick
+    };
+    // We only want mouse hover events on non-touch.
+    if (!Modernizr.touch) {
+      mapping.mouseenter =  function () { this.model.focus(); };
+    }
+    return mapping;
+  },
+
+  /**
+   * {@inheritdoc}
+   */
+  initialize: function () {
+    this.listenTo(this.model, 'change', this.render);
+  },
+
+  /**
+   * {@inheritdoc}
+   */
+  render: function () {
+    var isOpen = this.model.get('isOpen');
+    // The trigger should be visible when:
+    //  - the mouse hovered over the region,
+    //  - the trigger is locked,
+    //  - and for as long as the contextual menu is open.
+    var isVisible = this.model.get('isLocked') || this.model.get('regionIsHovered') || isOpen;
+
+    this.$el
+      // The open state determines if the links are visible.
+      .toggleClass('open', isOpen)
+      // Update the visibility of the trigger.
+      .find('.trigger').toggleClass('visually-hidden', !isVisible);
+
+    // Nested contextual region handling: hide any nested contextual triggers.
+    if ('isOpen' in this.model.changed) {
+      this.$el.closest('.contextual-region')
+        .find('.contextual .trigger:not(:first)')
+        .toggle(!isOpen);
+    }
+
+    return this;
+  }
+
+});
+
+})(Drupal, Backbone, Modernizr);