diff --git a/core/lib/Drupal/Core/Form/DialogFormTrait.php b/core/lib/Drupal/Core/Form/DialogFormTrait.php
new file mode 100644
index 0000000000..3142010f21
--- /dev/null
+++ b/core/lib/Drupal/Core/Form/DialogFormTrait.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Drupal\Core\Form;
+
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Ajax\RedirectCommand;
+use Drupal\Core\Ajax\ReplaceCommand;
+use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\CloseDialogCommand;
+use Drupal\Core\Url;
+
+/**
+ * Provides utilities for forms that want to be rendered in dialogs.
+ */
+trait DialogFormTrait {
+
+  /**
+   * Adds dialog support to a form.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   * @param bool $create_cancel
+   *   If TRUE the create submit button will be created.
+   * @param string $dialog_selector
+   *   The CSS selector for the associated dialog.
+   */
+  protected function buildFormDialog(array &$form, FormStateInterface $form_state, $create_cancel = FALSE, $dialog_selector = '#drupal-modal') {
+    if (!$this->isDialog()) {
+      return;
+    }
+    $form_state->set('dialog_selector', $dialog_selector);
+
+    $ajax_callback_added = FALSE;
+
+    if (!empty($form['actions']['submit'])) {
+      $form['actions']['submit']['#ajax'] = [
+        'callback' => '::submitFormDialog',
+        'event' => 'click',
+      ];
+      $ajax_callback_added = TRUE;
+    }
+
+    if ($create_cancel) {
+      $form['actions']['cancel'] = [
+        '#type' => 'submit',
+        '#value' => $this->t('Cancel'),
+        '#weight' => 100,
+      ];
+    }
+    if (!empty($form['actions']['cancel'])) {
+      // Replace 'Cancel' link button with a close dialog button.
+      $form['actions']['cancel'] = [
+        '#submit' => ['::noSubmit'],
+        '#limit_validation_errors' => [],
+        '#ajax' => [
+          'callback' => '::closeDialog',
+          'event' => 'click',
+        ],
+      ] + $form['actions']['cancel'];
+      $ajax_callback_added = TRUE;
+    }
+
+    if ($ajax_callback_added) {
+      $form['#attached']['library'][] = 'core/drupal.dialog.ajax';
+      $form['#attributes']['id'] = 'dialog-form';
+    }
+  }
+
+  /**
+   * Empty submit #ajax submit callback.
+   *
+   * This allows modal dialog to using ::submitCallback to validate and submit
+   * the form via one ajax required.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public function noSubmit(array &$form, FormStateInterface $form_state) {
+  }
+
+
+
+  /**
+   * Determines if the current request is for an AJAX dialog.
+   *
+   * @return bool
+   *   TRUE is the current request if for an AJAX dialog.
+   */
+  protected function isDialog() {
+    return in_array($this->getRequestWrapperFormat(), [
+      'drupal_ajax',
+      'drupal_dialog',
+      'drupal_modal',
+      'drupal_dialog.off_canvas',
+    ]);
+  }
+
+  /**
+   * Submit form dialog #ajax callback.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   An AJAX response that display validation error messages or redirects
+   *   to a URL
+   */
+  public function submitFormDialog(array &$form, FormStateInterface $form_state) {
+    $response = new AjaxResponse();
+    if ($form_state->hasAnyErrors()) {
+      $form['status_messages'] = [
+        '#type' => 'status_messages',
+        '#weight' => -1000,
+      ];
+      $command = new ReplaceCommand('#dialog-form', $form);
+    }
+    else {
+      if ($redirect_url = $this->getRedirectUrl()) {
+        $command = new RedirectCommand($redirect_url->setAbsolute()->toString());
+      }
+      else {
+        return $this->closeDialog($form, $form_state);
+      }
+    }
+    return $response->addCommand($command);
+  }
+
+  /**
+   * Close dialog #ajax callback.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   *
+   * @return bool|\Drupal\Core\Ajax\AjaxResponse
+   *   An AJAX response that display validation error messages.
+   */
+  public function closeDialog(array &$form, FormStateInterface $form_state) {
+    $selector = $form_state->get('dialog_selector');
+    return (new AjaxResponse())->addCommand(new CloseDialogCommand($selector));
+  }
+
+  /**
+   * @return mixed
+   */
+  protected function getRequestWrapperFormat() {
+    $wrapper_format = $this->getRequest()
+      ->get(MainContentViewSubscriber::WRAPPER_FORMAT);
+    return $wrapper_format;
+  }
+
+  /**
+   * Gets the form's redirect URL.
+   *
+   * Isolate a form's redirect URL/destination so that it can be used by
+   * ::submitFormDialog or ::submitForm.
+   *
+   * @return \Drupal\Core\Url|null
+   *   The redirect URL or NULL if dialog should just be closed.
+   */
+  protected function getRedirectUrl() {
+    return $this->getDestinationUrl();
+  }
+
+  /**
+   * Gets the URL from the destination service.
+   *
+   * @return \Drupal\Core\Url|null
+   *   The destination URL or NULL no destination available.
+   */
+  protected function getDestinationUrl() {
+    if ($destination = $this->getRedirectDestinationPath()) {
+      $options = UrlHelper::parse($destination);
+      return Url::fromUserInput('/' . ltrim($options['path'], '/'), $options);
+    }
+  }
+
+  /**
+   * Gets the redirect destination path if specified in request.
+   *
+   * \Drupal\Core\Routing\RedirectDestination::get() cannot be used directly
+   * because it will use <current> if 'destination' is not in the query string.
+   *
+   * @return string|null
+   *   The redirect path or NULL if it is not specified.
+   */
+  protected function getRedirectDestinationPath() {
+    if ($this->requestStack->getCurrentRequest()->get('destination')) {
+      return $this->getRedirectDestination()->get();
+    }
+  }
+
+}
diff --git a/core/modules/outside_in/tests/modules/off_canvas_test/src/Form/TestForm.php b/core/modules/outside_in/tests/modules/off_canvas_test/src/Form/TestForm.php
new file mode 100644
index 0000000000..3812e40ca9
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/off_canvas_test/src/Form/TestForm.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\off_canvas_test\Form;
+
+use Drupal\Core\Form\DialogFormTrait;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Just a test form.
+ */
+class TestForm extends FormBase {
+
+  use DialogFormTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return "off_canvas_test_form";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['force_error'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Force error?'),
+    ];
+    $form['actions'] = [
+      '#type' => 'actions',
+      'submit' => [
+        '#type' => 'submit',
+        '#value' => $this->t('Submit'),
+      ],
+      'cancel' => [
+        '#type' => 'submit',
+        '#value' => $this->t('Cancel'),
+      ],
+    ];
+    $this->buildFormDialog($form, $form_state, FALSE, '#drupal-off-canvas');
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    parent::validateForm($form, $form_state);
+    if ($form_state->getValue('force_error')) {
+      $form_state->setErrorByName('force_error', 'Validation error');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    drupal_set_message('submitted');
+  }
+
+}
diff --git a/core/modules/system/tests/modules/ajax_forms_test/ajax_forms_test.routing.yml b/core/modules/system/tests/modules/ajax_forms_test/ajax_forms_test.routing.yml
index 6271408417..835bada52d 100644
--- a/core/modules/system/tests/modules/ajax_forms_test/ajax_forms_test.routing.yml
+++ b/core/modules/system/tests/modules/ajax_forms_test/ajax_forms_test.routing.yml
@@ -38,3 +38,19 @@ ajax_forms_test.image_button_form:
   requirements:
     _access: 'TRUE'
 
+ajax_forms_test.dialog_form:
+  path: '/ajax_forms_test_dialog_form'
+  defaults:
+    _title: 'AJAX forms dialog test'
+    _form: '\Drupal\ajax_forms_test\Form\AjaxFormDialogForm'
+  requirements:
+    _access: 'TRUE'
+
+ajax_forms_test.dialog_links:
+  path: '/ajax_forms_test_dialog_links'
+  defaults:
+    _title: 'AJAX forms dialog links'
+    _controller: '\Drupal\ajax_forms_test\Controller\DialogLinksController::linksDisplay'
+  requirements:
+    _access: 'TRUE'
+
diff --git a/core/modules/system/tests/modules/ajax_forms_test/src/Controller/DialogLinksController.php b/core/modules/system/tests/modules/ajax_forms_test/src/Controller/DialogLinksController.php
new file mode 100644
index 0000000000..788ad35b15
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_forms_test/src/Controller/DialogLinksController.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\ajax_forms_test\Controller;
+
+use Drupal\Core\Url;
+
+/**
+ * Test controller for 2 different responses.
+ */
+class DialogLinksController {
+
+  /**
+   * Displays test links that will open in off-canvas dialog.
+   *
+   * @return array
+   *   Render array with links.
+   */
+  public function linksDisplay() {
+    $dialogs = [
+      [
+        'data-dialog-type' => 'modal',
+      ],
+      [
+        'data-dialog-type' => 'dialog',
+      ],
+      [
+        'data-dialog-type' => 'modal',
+        'data-dialog-renderer' => 'extra_wide',
+      ],
+    ];
+    $links = [];
+    foreach ($dialogs as $dialog_attributes) {
+      $format = $dialog_attributes['data-dialog-type']
+        . (isset($dialog_attributes['data-dialog-renderer']) ? '.' . $dialog_attributes['data-dialog-renderer'] : '');
+      $links[$format] = [
+        'title' => "Form $format!",
+        'url' => Url::fromRoute(
+          'ajax_forms_test.dialog_form',
+          [],
+          ['query' => ['destination' => '/ajax_forms_test_dialog_links']]
+        ),
+        'attributes' => [
+          'class' => ['use-ajax'],
+        ] + $dialog_attributes,
+      ];
+      $links["$format-no_dest"] = [
+        'title' => "Form $format-no_dest!",
+        'url' => Url::fromRoute(
+          'ajax_forms_test.dialog_form'
+        ),
+        'attributes' => [
+          'class' => ['use-ajax'],
+        ] + $dialog_attributes,
+      ];
+    }
+    return [
+      '#theme' => 'links',
+      '#links' => $links,
+      '#attached' => [
+        'library' => [
+          'core/drupal.dialog.ajax',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormDialogForm.php b/core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormDialogForm.php
new file mode 100644
index 0000000000..735dfdbe28
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_forms_test/src/Form/AjaxFormDialogForm.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\ajax_forms_test\Form;
+
+use Drupal\Core\Form\DialogFormTrait;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Just a test form.
+ */
+class AjaxFormDialogForm extends FormBase {
+
+  use DialogFormTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return "ajax_forms_test_dialog_form";
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['force_error'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Force error?'),
+    ];
+    $form['actions'] = [
+      '#type' => 'actions',
+      'submit' => [
+        '#type' => 'submit',
+        '#value' => $this->t('Submit'),
+      ],
+      'cancel' => [
+        '#type' => 'submit',
+        '#value' => $this->t('Cancel'),
+      ],
+    ];
+
+    $this->buildFormDialog($form, $form_state, FALSE);
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    parent::validateForm($form, $form_state);
+    if ($form_state->getValue('force_error')) {
+      $form_state->setErrorByName('force_error', 'Validation error');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    drupal_set_message('submitted');
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxDialogFormTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxDialogFormTest.php
new file mode 100644
index 0000000000..8caddfe8e0
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/AjaxDialogFormTest.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Ajax;
+
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+
+/**
+ * Tests AJAX responses.
+ *
+ * @group Ajax
+ */
+class AjaxDialogFormTest extends JavascriptTestBase {
+
+  const DIALOG_LINKS_PATH = '/ajax_forms_test_dialog_links';
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['dialog_renderer_test', 'ajax_forms_test'];
+
+  /**
+   * Tests form errors in the Off-Canvas dialog.
+   */
+  public function testFormErrors() {
+    $web_assert = $this->assertSession();
+    $page = $this->getSession()->getPage();
+    $dialog_formats = ['modal', 'dialog', 'modal.extra_wide'];
+
+    foreach ($dialog_formats as $dialog_format) {
+      // Submit form with no error and sending a destination.
+      $this->drupalGet(static::DIALOG_LINKS_PATH);
+      $page->clickLink("Form $dialog_format!");
+      $this->waitForDialogToOpen();
+      $page->pressButton('Submit');
+      $web_assert->assertWaitOnAjaxRequest();
+      // Make sure the changes are present.
+      $this->assertNotEmpty($web_assert->waitForElement('css', 'div.messages.messages--status:contains(submitted)'));
+      $web_assert->elementNotContains('css', 'body', 'Validation error');
+      return;
+
+      // Submit form with an error and sending a destination.
+      $this->drupalGet(static::DIALOG_LINKS_PATH);
+      $page->clickLink("Form $dialog_format!");
+      $this->waitForDialogToOpen();
+      $page->checkField('Force error?');
+      $page->pressButton('Submit');
+      $web_assert->assertWaitOnAjaxRequest();
+      $web_assert->elementNotContains('css', 'body', 'submitted');
+      $web_assert->elementContains('css', '#drupal-modal', 'Validation error');
+
+      // Submit form with no error and NOT sending a destination.
+      $this->drupalGet(static::DIALOG_LINKS_PATH);
+      $page->clickLink("Form $dialog_format-no_dest!");
+      $this->waitForDialogToOpen();
+      $page->pressButton('Submit');
+      $web_assert->assertWaitOnAjaxRequest();
+      // Make sure the changes are present.
+      $this->assertEmpty($web_assert->waitForElement('css', 'div.messages.messages--status:contains(submitted)'));
+      $web_assert->elementNotContains('css', 'body', 'Validation error');
+      // If no validation error and no destination provided page will not be
+      // redirected but the dialog should be closed.
+      $this->waitForNoElement('css', '#drupal-modal');
+
+      $this->drupalGet(static::DIALOG_LINKS_PATH);
+
+      // Submit form with error and NOT sending a destination.
+      $this->drupalGet(static::DIALOG_LINKS_PATH);
+      $page->clickLink("Form $dialog_format-no_dest!");
+      $this->waitForDialogToOpen();
+      $page->checkField('Force error?');
+      $page->pressButton('Submit');
+      $web_assert->assertWaitOnAjaxRequest();
+      $web_assert->elementNotContains('css', 'body', 'submitted');
+      $web_assert->elementContains('css', '#drupal-modal', 'Validation error');
+    }
+  }
+
+  /**
+   * Waits for the dialog to open.
+   */
+  protected function waitForDialogToOpen() {
+    $web_assert = $this->assertSession();
+    $web_assert->assertWaitOnAjaxRequest();
+    $this->assertElementVisibleAfterWait('css', '#drupal-modal');
+  }
+
+  /**
+   * Asserts the specified selector is visible after a wait.
+   *
+   * @param string $selector
+   *   The selector engine name. See ElementInterface::findAll() for the
+   *   supported selectors.
+   * @param string|array $locator
+   *   The selector locator.
+   * @param int $timeout
+   *   (Optional) Timeout in milliseconds, defaults to 10000.
+   */
+  protected function assertElementVisibleAfterWait($selector, $locator, $timeout = 10000) {
+    $this->assertNotEmpty($this->assertSession()->waitForElementVisible($selector, $locator, $timeout));
+  }
+
+  /**
+   * Waits for an element to be removed from the page.
+   *
+   * @param string $selector
+   *   CSS selector.
+   * @param int $timeout
+   *   (optional) Timeout in milliseconds, defaults to 10000.
+   *
+   * @todo Remove in https://www.drupal.org/node/2892440
+   */
+  protected function waitForNoElement($selector, $timeout = 10000) {
+    $condition = "(jQuery('$selector').length == 0)";
+    $this->assertJsCondition($condition, $timeout);
+  }
+
+}
