diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js
index 6248e46..e690611 100644
--- a/core/misc/ajax.es6.js
+++ b/core/misc/ajax.es6.js
@@ -46,26 +46,7 @@ function loadAjaxBehavior(base) {
         }
       }
 
-      // Bind Ajax behaviors to all items showing the class.
-      $('.use-ajax').once('ajax').each(function () {
-        const element_settings = {};
-        // Clicked links look better with the throbber than the progress bar.
-        element_settings.progress = { type: 'throbber' };
-
-        // For anchor tags, these will go to the target of the anchor rather
-        // than the usual location.
-        const href = $(this).attr('href');
-        if (href) {
-          element_settings.url = href;
-          element_settings.event = 'click';
-        }
-        element_settings.dialogType = $(this).data('dialog-type');
-        element_settings.dialogRenderer = $(this).data('dialog-renderer');
-        element_settings.dialog = $(this).data('dialog-options');
-        element_settings.base = $(this).attr('id');
-        element_settings.element = this;
-        Drupal.ajax(element_settings);
-      });
+      Drupal.ajax.bindAjaxLinks(document.body);
 
       // This class means to submit the form to the action using Ajax.
       $('.use-ajax-submit').once('ajax').each(function () {
@@ -271,6 +252,39 @@ function loadAjaxBehavior(base) {
   };
 
   /**
+   * Bind Ajax functionality to links that use the 'use-ajax' class.
+   *
+   * @param {HTMLElement} element
+   *   Element to enable Ajax functionality for.
+   */
+  Drupal.ajax.bindAjaxLinks = (element) => {
+    // Bind Ajax behaviors to all items showing the class.
+    $(element).find('.use-ajax').once('ajax').each((i, ajaxLink) => {
+      const $linkElement = $(ajaxLink);
+
+      const elementSettings = {
+        // Clicked links look better with the throbber than the progress bar.
+        progress: { type: 'throbber' },
+        dialogType: $linkElement.data('dialog-type'),
+        dialog: $linkElement.data('dialog-options'),
+        dialogRenderer: $linkElement.data('dialog-renderer'),
+        base: $linkElement.attr('id'),
+        element: ajaxLink,
+      };
+      const href = $linkElement.attr('href');
+      /**
+       * For anchor tags, these will go to the target of the anchor rather
+       * than the usual location.
+       */
+      if (href) {
+        elementSettings.url = href;
+        elementSettings.event = 'click';
+      }
+      Drupal.ajax(elementSettings);
+    });
+  };
+
+  /**
    * Settings for an Ajax object.
    *
    * @typedef {object} Drupal.Ajax~element_settings
@@ -1338,4 +1352,5 @@ else if (effect.showEffect !== 'show') {
       }
     },
   };
+
 }(jQuery, window, Drupal, drupalSettings));
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
index 5ea5242..b6c0948 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -27,23 +27,7 @@ function loadAjaxBehavior(base) {
         }
       }
 
-      $('.use-ajax').once('ajax').each(function () {
-        var element_settings = {};
-
-        element_settings.progress = { type: 'throbber' };
-
-        var href = $(this).attr('href');
-        if (href) {
-          element_settings.url = href;
-          element_settings.event = 'click';
-        }
-        element_settings.dialogType = $(this).data('dialog-type');
-        element_settings.dialogRenderer = $(this).data('dialog-renderer');
-        element_settings.dialog = $(this).data('dialog-options');
-        element_settings.base = $(this).attr('id');
-        element_settings.element = this;
-        Drupal.ajax(element_settings);
-      });
+      Drupal.ajax.bindAjaxLinks(document.body);
 
       $('.use-ajax-submit').once('ajax').each(function () {
         var element_settings = {};
@@ -139,6 +123,28 @@ function loadAjaxBehavior(base) {
     });
   };
 
+  Drupal.ajax.bindAjaxLinks = function (element) {
+    $(element).find('.use-ajax').once('ajax').each(function (i, ajaxLink) {
+      var $linkElement = $(ajaxLink);
+
+      var elementSettings = {
+        progress: { type: 'throbber' },
+        dialogType: $linkElement.data('dialog-type'),
+        dialog: $linkElement.data('dialog-options'),
+        dialogRenderer: $linkElement.data('dialog-renderer'),
+        base: $linkElement.attr('id'),
+        element: ajaxLink
+      };
+      var href = $linkElement.attr('href');
+
+      if (href) {
+        elementSettings.url = href;
+        elementSettings.event = 'click';
+      }
+      Drupal.ajax(elementSettings);
+    });
+  };
+
   Drupal.Ajax = function (base, element, element_settings) {
     var defaults = {
       event: element ? 'mousedown' : null,
diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml
index 51281af..ec90680 100644
--- a/core/modules/contextual/contextual.libraries.yml
+++ b/core/modules/contextual/contextual.libraries.yml
@@ -20,6 +20,7 @@ drupal.contextual-links:
   dependencies:
     - core/jquery
     - core/drupal
+    - core/drupal.ajax
     - core/drupalSettings
     - core/backbone
     - core/modernizr
diff --git a/core/modules/contextual/js/contextual.es6.js b/core/modules/contextual/js/contextual.es6.js
index 8c2b1ae..286e1ef 100644
--- a/core/modules/contextual/js/contextual.es6.js
+++ b/core/modules/contextual/js/contextual.es6.js
@@ -249,4 +249,19 @@ function adjustIfNestedAndOverlapping($contextual) {
   Drupal.theme.contextualTrigger = function () {
     return '<button class="trigger visually-hidden focusable" type="button"></button>';
   };
+
+  /**
+   * Bind Ajax contextual links when added.
+   *
+   * @param {jQuery.Event} event
+   *   The `drupalContextualLinkAdded` event.
+   * @param {object} data
+   *   An object containing the data relevant to the event.
+   *
+   * @listens event:drupalContextualLinkAdded
+   */
+  $(document).on('drupalContextualLinkAdded', (event, data) => {
+    Drupal.ajax.bindAjaxLinks(data.$el[0]);
+  });
+
 }(jQuery, Drupal, drupalSettings, _, Backbone, window.JSON, window.sessionStorage));
diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js
index a23ac66..ed210d0 100644
--- a/core/modules/contextual/js/contextual.js
+++ b/core/modules/contextual/js/contextual.js
@@ -144,4 +144,8 @@ function adjustIfNestedAndOverlapping($contextual) {
   Drupal.theme.contextualTrigger = function () {
     return '<button class="trigger visually-hidden focusable" type="button"></button>';
   };
+
+  $(document).on('drupalContextualLinkAdded', function (event, data) {
+    Drupal.ajax.bindAjaxLinks(data.$el[0]);
+  });
 })(jQuery, Drupal, drupalSettings, _, Backbone, window.JSON, window.sessionStorage);
\ No newline at end of file
diff --git a/core/modules/contextual/tests/modules/contextual_test/contextual_test.links.contextual.yml b/core/modules/contextual/tests/modules/contextual_test/contextual_test.links.contextual.yml
index 35e03b7..1738756 100644
--- a/core/modules/contextual/tests/modules/contextual_test/contextual_test.links.contextual.yml
+++ b/core/modules/contextual/tests/modules/contextual_test/contextual_test.links.contextual.yml
@@ -2,3 +2,11 @@ contextual_test:
   title: 'Test Link'
   route_name: 'contextual_test'
   group: 'contextual_test'
+contextual_test_ajax:
+  title: 'Test Link with Ajax'
+  route_name: 'contextual_test'
+  group: 'contextual_test'
+  options:
+    attributes:
+      class: ['use-ajax']
+      data-dialog-type: 'modal'
diff --git a/core/modules/contextual/tests/modules/contextual_test/contextual_test.module b/core/modules/contextual/tests/modules/contextual_test/contextual_test.module
index b8f2e60..d57255c 100644
--- a/core/modules/contextual/tests/modules/contextual_test/contextual_test.module
+++ b/core/modules/contextual/tests/modules/contextual_test/contextual_test.module
@@ -15,3 +15,23 @@ function contextual_test_block_view_alter(array &$build, BlockPluginInterface $b
     'route_parameters' => [],
   ];
 }
+
+/**
+ * Implements hook_contextual_links_view_alter().
+ *
+ * @todo Apparently this too late to attach the library?
+ * It won't work without contextual_test_page_attachments_alter()
+ * Is that a problem? Should the contextual module itself do the attaching?
+ */
+function contextual_test_contextual_links_view_alter(&$element, $items) {
+  if (isset($element['#links']['contextual-test-ajax'])) {
+    $element['#attached']['library'][] = 'core/drupal.dialog.ajax';
+  }
+}
+
+/**
+ * Implements hook_page_attachments_alter().
+ */
+function contextual_test_page_attachments_alter(array &$attachments) {
+  $attachments['#attached']['library'][] = 'core/drupal.dialog.ajax';
+}
diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
index a24d2ba..de83692 100644
--- a/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
+++ b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
@@ -69,6 +69,17 @@ public function testContextualLinksClick() {
     $this->clickContextualLink('#block-branding', 'Test Link');
     $this->assertSession()->pageTextContains('Everything is contextual!');
 
+    // Test click a contextual link that uses ajax.
+    $this->drupalGet('user');
+    $this->assertSession()->assertWaitOnAjaxRequest();
+    $current_page_string = 'NOT_RELOADED_IF_ON_PAGE';
+    $this->getSession()->executeScript('document.body.appendChild(document.createTextNode("' . $current_page_string . '"));');
+    $this->clickContextualLink('#block-branding', 'Test Link with Ajax');
+    $this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '#drupal-modal'));
+    $this->assertSession()->elementContains('css', '#drupal-modal', 'Everything is contextual!');
+    // Check to make sure that page was not reloaded.
+    $this->assertSession()->pageTextContains($current_page_string);
+
     // Test clicking contextual link with toolbar.
     $this->container->get('module_installer')->install(['toolbar']);
     $this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), ['access toolbar']);
diff --git a/core/modules/settings_tray/js/settings_tray.es6.js b/core/modules/settings_tray/js/settings_tray.es6.js
index b4edd84..6850e74 100644
--- a/core/modules/settings_tray/js/settings_tray.es6.js
+++ b/core/modules/settings_tray/js/settings_tray.es6.js
@@ -149,6 +149,27 @@ function toggleEditMode() {
   }
 
   /**
+   * Prepares Ajax links to work with off-canvas and Settings Tray module.
+   */
+  function prepareAjaxLinks() {
+    // Find all Ajax instances that use the 'off_canvas' renderer.
+    Drupal.ajax.instances
+    // If there is an element and the renderer is 'off_canvas' then we want
+    // to add our changes.
+      .filter(instance => instance && $(instance.element).attr('data-dialog-renderer') === 'off_canvas')
+      // Loop through all Ajax instances that use the 'off_canvas' renderer to
+      // set active editable ID.
+      .forEach((instance) => {
+        // Check to make sure existing dialogOptions aren't overridden.
+        if (!('dialogOptions' in instance.options.data)) {
+          instance.options.data.dialogOptions = {};
+        }
+        instance.options.data.dialogOptions.settingsTrayActiveEditableId = $(instance.element).parents('.settings-tray-editable').attr('id');
+        instance.progress = { type: 'fullscreen' };
+      });
+  }
+
+  /**
    * Reacts to contextual links being added.
    *
    * @param {jQuery.Event} event
@@ -160,6 +181,12 @@ function toggleEditMode() {
    */
   $(document).on('drupalContextualLinkAdded', (event, data) => {
 
+    /**
+     * When contextual links are add we need to set extra properties on the
+     * instances in Drupal.ajax.instances for them to work with Edit Mode.
+     */
+    prepareAjaxLinks();
+
     // When the first contextual link is added to the page set Edit Mode.
     $('body').once('settings_tray.edit_mode_init').each(() => {
       const editMode = localStorage.getItem('Drupal.contextualToolbar.isViewing') === 'false';
@@ -169,12 +196,6 @@ function toggleEditMode() {
     });
 
     /**
-     * Bind Ajax behaviors to all items showing the class.
-     * @todo Fix contextual links to work with use-ajax links in
-     * https://www.drupal.org/node/2764931.
-     */
-    Drupal.attachBehaviors(data.$el[0]);
-    /**
      * Bind a listener to all 'Quick edit' links for blocks. Click "Edit" button
      * in toolbar to force Contextual Edit which starts Settings Tray edit
      * mode also.
@@ -212,21 +233,6 @@ function toggleEditMode() {
   Drupal.behaviors.toggleEditMode = {
     attach() {
       $(toggleEditSelector).once('settingstray').on('click.settingstray', toggleEditMode);
-      // Find all Ajax instances that use the 'off_canvas' renderer.
-      Drupal.ajax.instances
-        // If there is an element and the renderer is 'off_canvas' then we want
-        // to add our changes.
-        .filter(instance => instance && $(instance.element).attr('data-dialog-renderer') === 'off_canvas')
-        // Loop through all Ajax instances that use the 'off_canvas' renderer to
-        // set active editable ID.
-        .forEach((instance) => {
-          // Check to make sure existing dialogOptions aren't overridden.
-          if (!('dialogOptions' in instance.options.data)) {
-            instance.options.data.dialogOptions = {};
-          }
-          instance.options.data.dialogOptions.settingsTrayActiveEditableId = $(instance.element).parents('.settings-tray-editable').attr('id');
-          instance.progress = { type: 'fullscreen' };
-        });
     },
   };
 
diff --git a/core/modules/settings_tray/js/settings_tray.js b/core/modules/settings_tray/js/settings_tray.js
index 88cbb0e..78c5b60 100644
--- a/core/modules/settings_tray/js/settings_tray.js
+++ b/core/modules/settings_tray/js/settings_tray.js
@@ -93,7 +93,21 @@ function toggleEditMode() {
     setEditModeState(!isInEditMode());
   }
 
+  function prepareAjaxLinks() {
+    Drupal.ajax.instances.filter(function (instance) {
+      return instance && $(instance.element).attr('data-dialog-renderer') === 'off_canvas';
+    }).forEach(function (instance) {
+      if (!('dialogOptions' in instance.options.data)) {
+        instance.options.data.dialogOptions = {};
+      }
+      instance.options.data.dialogOptions.settingsTrayActiveEditableId = $(instance.element).parents('.settings-tray-editable').attr('id');
+      instance.progress = { type: 'fullscreen' };
+    });
+  }
+
   $(document).on('drupalContextualLinkAdded', function (event, data) {
+    prepareAjaxLinks();
+
     $('body').once('settings_tray.edit_mode_init').each(function () {
       var editMode = localStorage.getItem('Drupal.contextualToolbar.isViewing') === 'false';
       if (editMode) {
@@ -101,8 +115,6 @@ function toggleEditMode() {
       }
     });
 
-    Drupal.attachBehaviors(data.$el[0]);
-
     data.$el.find(blockConfigureSelector).on('click.settingstray', function () {
       if (!isInEditMode()) {
         $(toggleEditSelector).trigger('click').trigger('click.settings_tray');
@@ -122,16 +134,6 @@ function toggleEditMode() {
   Drupal.behaviors.toggleEditMode = {
     attach: function attach() {
       $(toggleEditSelector).once('settingstray').on('click.settingstray', toggleEditMode);
-
-      Drupal.ajax.instances.filter(function (instance) {
-        return instance && $(instance.element).attr('data-dialog-renderer') === 'off_canvas';
-      }).forEach(function (instance) {
-        if (!('dialogOptions' in instance.options.data)) {
-          instance.options.data.dialogOptions = {};
-        }
-        instance.options.data.dialogOptions.settingsTrayActiveEditableId = $(instance.element).parents('.settings-tray-editable').attr('id');
-        instance.progress = { type: 'fullscreen' };
-      });
     }
   };
 
