diff --git a/core/includes/ajax.inc b/core/includes/ajax.inc
index 1edd871..a3efef4 100644
--- a/core/includes/ajax.inc
+++ b/core/includes/ajax.inc
@@ -474,7 +474,7 @@ function ajax_prepare_response($page_callback_result) {
     // #ajax['method']. The default method is 'replaceWith', which completely
     // replaces the old wrapper element and its content with the new HTML.
     $html = is_string($page_callback_result) ? $page_callback_result : drupal_render($page_callback_result);
-    $commands[] = ajax_command_insert(NULL, $html);
+    $commands[] = ajax_command_insert(NULL, $html) + array('title' => drupal_get_title());
     // Add the status messages inside the new content's wrapper element, so that
     // on subsequent Ajax requests, it is treated as old content.
     $commands[] = ajax_command_prepend(NULL, theme('status_messages'));
@@ -531,8 +531,8 @@ function ajax_pre_render_element($element) {
   // Initialize #ajax_processed, so we do not process this element again.
   $element['#ajax_processed'] = FALSE;
 
-  // Nothing to do if there is neither a callback nor a path.
-  if (!(isset($element['#ajax']['callback']) || isset($element['#ajax']['path']))) {
+  // Nothing to do if there are no Ajax settings.
+  if (empty($element['#ajax'])) {
     return $element;
   }
 
@@ -589,12 +589,15 @@ function ajax_pre_render_element($element) {
   if (isset($element['#ajax']['event'])) {
     $element['#attached']['library'][] = array('system', 'jquery.form');
     $element['#attached']['library'][] = array('system', 'drupal.ajax');
+    if (!empty($element['#ajax']['modal'])) {
+      $element['#attached']['library'][] = array('system', 'drupal.dialog');
+    }
 
     $settings = $element['#ajax'];
 
     // Assign default settings.
     $settings += array(
-      'path' => 'system/ajax',
+      'path' => isset($settings['callback']) ? 'system/ajax' : NULL,
       'options' => array(),
     );
 
@@ -604,7 +607,7 @@ function ajax_pre_render_element($element) {
     }
 
     // Change path to URL.
-    $settings['url'] = url($settings['path'], $settings['options']);
+    $settings['url'] = isset($settings['path']) ? url($settings['path'], $settings['options']) : NULL;
     unset($settings['path'], $settings['options']);
 
     // Add special data to $settings['submit'] so that when this element
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 3ce399b..c10a3ac 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -1739,8 +1739,24 @@ function theme_links($variables) {
         if ($is_current_path && $is_current_language) {
           $class[] = 'active';
         }
-        // Pass in $link as $options, they share the same keys.
-        $item = l($link['title'], $link['href'], $link);
+        // @todo Reconcile Views usage of 'ajax' as a boolean with the rest of
+        //   core's usage of it as an array.
+        if (isset($link['ajax']) && is_array($link['ajax'])) {
+          // To attach Ajax behavior, render a link element, rather than just
+          // call l().
+          $link_element = array(
+            '#type' => 'link',
+            '#title' => $link['title'],
+            '#href' => $link['href'],
+            '#ajax' => $link['ajax'],
+            '#options' => array_diff_key($link, drupal_map_assoc(array('title', 'href', 'ajax'))),
+          );
+          $item = drupal_render($link_element);
+        }
+        else {
+          // Pass in $link as $options, they share the same keys.
+          $item = l($link['title'], $link['href'], $link);
+        }
       }
       // Handle title-only text items.
       else {
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
index 58db308..65ccdc8 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -78,6 +78,37 @@ Drupal.behaviors.AJAX = {
 };
 
 /**
+ * Makes sure that a hidden dom element with id 'drupal-modal' exists.
+ */
+Drupal.behaviors.AJAXModalDialog = {
+  attach: function () {
+    if (!$('#drupal-modal').length) {
+      $('<div id="drupal-modal" />').hide().appendTo('body');
+    }
+  }
+};
+
+/**
+ * Binds a listener on modal dialog creation to handle the cancel link.
+ */
+$(window).on('dialog:aftercreate', function (e, dialog, $element, settings) {
+  if (settings.modal) {
+    $element.on('click.ajaxmodal', '.modal-cancel', function (e) {
+      dialog.close('cancel');
+      e.preventDefault();
+      e.stopPropagation();
+    });
+  }
+});
+
+/**
+ * Removes all 'ajaxmodal' listeners.
+ */
+$(window).on('dialog:beforeclose', function (e, dialog, $element) {
+  $element.off('.ajaxmodal');
+});
+
+/**
  * Ajax object.
  *
  * All Ajax objects on a page are accessible through the global Drupal.ajax
@@ -101,7 +132,6 @@ Drupal.behaviors.AJAX = {
  */
 Drupal.ajax = function (base, element, element_settings) {
   var defaults = {
-    url: 'system/ajax',
     event: 'mousedown',
     keypress: true,
     selector: '#' + base,
@@ -119,9 +149,53 @@ Drupal.ajax = function (base, element, element_settings) {
 
   $.extend(this, defaults, element_settings);
 
+  // @todo Remove this after refactoring the PHP code to:
+  //   - Call this 'selector'.
+  //   - Include the '#' for ID-based selectors.
+  //   - Support non-ID-based selectors.
+  if (this.wrapper) {
+    this.wrapper = '#' + this.wrapper;
+  }
+
+  // For Ajax responses that are wanted in a modal, use the needed wrapper and
+  // method.
+  if (this.modal) {
+    this.wrapper = '#drupal-modal';
+    this.method = 'html';
+  }
+
   this.element = element;
   this.element_settings = element_settings;
 
+  // If there isn't a form, jQuery.ajax() will be used instead, allowing us to
+  // bind Ajax to links as well.
+  if (this.element.form) {
+    this.form = $(this.element.form);
+  }
+
+  // If no Ajax callback URL was given, use the link href or form action.
+  if (!this.url) {
+    if ($(element).is('a')) {
+      this.url = $(element).attr('href');
+    }
+    else if (element.form) {
+      this.url = this.form.attr('action');
+
+      // @todo If there's a file input on this form, then jQuery will submit the
+      //   AJAX response with a hidden Iframe rather than the XHR object. If the
+      //   response to the submission is an HTTP redirect, then the Iframe will
+      //   follow it, but the server won't content negotiate it correctly,
+      //   because there won't be an ajax_iframe_upload POST variable. Until we
+      //   figure out a work around to this problem, we prevent AJAX-enabling
+      //   elements that submit to the same URL as the form when there's a file
+      //   input. For example, this means the Delete button on the edit form of
+      //   an Article node doesn't open its confirmation form in a modal.
+      if (this.form.find(':file').length) {
+        return;
+      }
+    }
+  }
+
   // Replacing 'nojs' with 'ajax' in the URL allows for an easy method to let
   // the server detect when it needs to degrade gracefully.
   // There are four scenarios to check for:
@@ -129,14 +203,7 @@ Drupal.ajax = function (base, element, element_settings) {
   // 2. /nojs$ - The end of a URL string.
   // 3. /nojs? - Followed by a query (e.g. path/nojs?destination=foobar).
   // 4. /nojs# - Followed by a fragment (e.g.: path/nojs#myfragment).
-  this.url = element_settings.url.replace(/\/nojs(\/|$|\?|#)/g, '/ajax$1');
-  this.wrapper = '#' + element_settings.wrapper;
-
-  // If there isn't a form, jQuery.ajax() will be used instead, allowing us to
-  // bind Ajax to links as well.
-  if (this.element.form) {
-    this.form = $(this.element.form);
-  }
+  this.url = this.url.replace(/\/nojs(\/|$|\?|#)/g, '/ajax$1');
 
   // Set the options for the ajaxSubmit function.
   // The 'this' variable will not persist inside of the options object.
@@ -520,6 +587,10 @@ Drupal.ajax.prototype.commands = {
     // Add the new content to the page.
     wrapper[method](new_content);
 
+    if (ajax.modal && (response.title || !$(ajax.wrapper).length)) {
+      Drupal.dialog(wrapper, {title: response.title}).showModal();
+    }
+
     // Immediately hide the new content if we're using any effects.
     if (effect.showEffect !== 'show') {
       new_content.hide();
diff --git a/core/misc/dialog.js b/core/misc/dialog.js
new file mode 100644
index 0000000..d96d31b
--- /dev/null
+++ b/core/misc/dialog.js
@@ -0,0 +1,56 @@
+/**
+ * @file
+ *
+ * Dialog API inspired by HTML5 dialog element:
+ * http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#the-dialog-element
+ */
+(function ($, Drupal, drupalSettings) {
+
+  "use strict";
+
+  drupalSettings.modal = {
+    autoOpen: true,
+    dialogClass: '',
+    close: function (e) {
+      Drupal.detachBehaviors(e.target, null, 'unload');
+    }
+  };
+
+  Drupal.dialog = function (element, options) {
+
+    function openDialog (settings) {
+      settings = $.extend(settings, defaults);
+      // Trigger a global event to allow scripts to bind events to the dialog.
+      $(window).trigger('dialog:beforecreate', [dialog, $element, settings]);
+      $element.dialog(settings);
+      dialog.open = true;
+      $(window).trigger('dialog:aftercreate', [dialog, $element, settings]);
+    }
+
+    function closeDialog (value) {
+      $(window).trigger('dialog:beforeclose', [dialog, $element]);
+      $element.dialog('close');
+      dialog.returnValue = value;
+      dialog.open = false;
+      $(window).trigger('dialog:afterclose', [dialog, $element]);
+    }
+
+    var undef;
+    var $element = $(element);
+    var defaults = $.extend(options, drupalSettings.modal);
+    var dialog = {
+      open: false,
+      returnValue: undef,
+      show: function () {
+        openDialog({ modal: false });
+      },
+      showModal: function () {
+        openDialog({ modal: true });
+      },
+      close: closeDialog
+    };
+
+    return dialog;
+  };
+
+})(jQuery, Drupal, drupalSettings);
diff --git a/core/modules/node/lib/Drupal/node/NodeFormController.php b/core/modules/node/lib/Drupal/node/NodeFormController.php
index 215ad81..f8ae021 100644
--- a/core/modules/node/lib/Drupal/node/NodeFormController.php
+++ b/core/modules/node/lib/Drupal/node/NodeFormController.php
@@ -267,6 +267,10 @@ protected function actions(array $form, array &$form_state) {
     $element['submit']['#access'] = $preview_mode != DRUPAL_REQUIRED || (!form_get_errors() && isset($form_state['node_preview']));
     $element['delete']['#access'] = node_access('delete', $node);
 
+    // @todo Move this to EntityFormController::actions() so it applies to all
+    //   entity types by default?
+    $element['delete']['#ajax']['modal'] = TRUE;
+
     return $element;
   }
 
diff --git a/core/modules/node/node.admin.inc b/core/modules/node/node.admin.inc
index e171b75..de3537b 100644
--- a/core/modules/node/node.admin.inc
+++ b/core/modules/node/node.admin.inc
@@ -557,6 +557,7 @@ function node_admin_nodes() {
       $operations['delete'] = array(
         'title' => t('delete'),
         'href' => 'node/' . $node->nid . '/delete',
+        'ajax' => array('modal' => TRUE),
         'query' => $destination,
       );
     }
diff --git a/core/modules/system/system.module b/core/modules/system/system.module
index 950fee6..41f7294 100644
--- a/core/modules/system/system.module
+++ b/core/modules/system/system.module
@@ -1171,6 +1171,21 @@ function system_library_info() {
     ),
   );
 
+  // Drupal's Ajax framework, modal component.
+  $libraries['drupal.dialog'] = array(
+    'title' => 'Drupal Dialog',
+    'version' => VERSION,
+    'js' => array(
+      'core/misc/dialog.js' => array('group' => JS_LIBRARY),
+    ),
+    'dependencies' => array(
+      array('system', 'jquery'),
+      array('system', 'drupal'),
+      array('system', 'drupalSettings'),
+      array('system', 'jquery.ui.dialog')
+    ),
+  );
+
   // Drupal's states library.
   $libraries['drupal.states'] = array(
     'title' => 'Drupal states',
@@ -3350,6 +3365,9 @@ function confirm_form($form, $question, $path, $description = NULL, $yes = NULL,
     '#type' => 'link',
     '#title' => $no ? $no : t('Cancel'),
     '#href' => $options['path'],
+    '#attributes' => array(
+      'class' => array('modal-cancel'),
+    ),
     '#options' => $options,
   );
   // By default, render the form using theme_confirm_form().
