diff --git a/core/lib/Drupal/Core/Ajax/AnnounceCommand.php b/core/lib/Drupal/Core/Ajax/AnnounceCommand.php
index aa58bdbfee03172809fbd0fff0bcdbaf298f5005..b32f4e62dc40d909a6ad156e0ea08932a91081e3 100644
--- a/core/lib/Drupal/Core/Ajax/AnnounceCommand.php
+++ b/core/lib/Drupal/Core/Ajax/AnnounceCommand.php
@@ -7,6 +7,15 @@
 /**
  * AJAX command for a JavaScript Drupal.announce() call.
  *
+ * Developers should be extra careful if this command and
+ * \Drupal\Core\Ajax\MessageCommand are included in the same response. By
+ * default, MessageCommmand will also call Drupal.announce() and announce the
+ * message to the screen reader (unless the option to suppress announcements is
+ * passed to the constructor). Manual testing with a screen reader is strongly
+ * recommended.
+ *
+ * @see \Drupal\Core\Ajax\MessageCommand
+ *
  * @ingroup ajax
  */
 class AnnounceCommand implements CommandInterface, CommandWithAttachedAssetsInterface {
diff --git a/core/lib/Drupal/Core/Ajax/MessageCommand.php b/core/lib/Drupal/Core/Ajax/MessageCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..ba5b14e738e1f6670bdba60bcb6983e7f9382df5
--- /dev/null
+++ b/core/lib/Drupal/Core/Ajax/MessageCommand.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Drupal\Core\Ajax;
+
+use Drupal\Core\Asset\AttachedAssets;
+
+/**
+ * AJAX command for a JavaScript Drupal.message() call.
+ *
+ * Developers should be extra careful if this command and
+ * \Drupal\Core\Ajax\AnnounceCommand are included in the same response. Unless
+ * the `announce` option is set to an empty string (''), this command will
+ * result in the message being announced to screen readers. When combined with
+ * AnnounceCommand, this may result in unexpected behavior. Manual testing with
+ * a screen reader is strongly recommended.
+ *
+ * Here are examples of how to suppress announcements:
+ * @code
+ *   $command = new MessageCommand("I won't be announced", NULL, [
+ *     'announce' => '',
+ *   ]);
+ * @endcode
+ *
+ * @see \Drupal\Core\Ajax\AnnounceCommand
+ *
+ * @ingroup ajax
+ */
+class MessageCommand implements CommandInterface, CommandWithAttachedAssetsInterface {
+
+  /**
+   * The message text.
+   *
+   * @var string
+   */
+  protected $message;
+
+  /**
+   * Whether to clear previous messages.
+   *
+   * @var bool
+   */
+  protected $clearPrevious;
+
+  /**
+   * The query selector for the element the message will appear in.
+   *
+   * @var string
+   */
+  protected $wrapperQuerySelector;
+
+  /**
+   * The options passed to Drupal.message().add().
+   *
+   * @var array
+   */
+  protected $options;
+
+  /**
+   * Constructs a MessageCommand object.
+   *
+   * @param string $message
+   *   The text of the message.
+   * @param string|null $wrapper_query_selector
+   *   The query selector of the element to display messages in when they
+   *   should be displayed somewhere other than the default.
+   *   @see Drupal.Message.defaultWrapper()
+   * @param array $options
+   *   The options passed to Drupal.message().add().
+   * @param bool $clear_previous
+   *   If TRUE, previous messages will be cleared first.
+   */
+  public function __construct($message, $wrapper_query_selector = NULL, array $options = [], $clear_previous = TRUE) {
+    $this->message = $message;
+    $this->wrapperQuerySelector = $wrapper_query_selector;
+    $this->options = $options;
+    $this->clearPrevious = $clear_previous;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    return [
+      'command' => 'message',
+      'message' => $this->message,
+      'messageWrapperQuerySelector' => $this->wrapperQuerySelector,
+      'messageOptions' => $this->options,
+      'clearPrevious' => $this->clearPrevious,
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAttachedAssets() {
+    $assets = new AttachedAssets();
+    $assets->setLibraries(['core/drupal.message']);
+    return $assets;
+  }
+
+}
diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js
index 1b26397c5c1568acb0c402e67b6a12cd1f693d46..905559c921a57d7587d05b16cae96775e7f21924 100644
--- a/core/misc/ajax.es6.js
+++ b/core/misc/ajax.es6.js
@@ -1562,5 +1562,31 @@
         } while (match);
       }
     },
+
+    /**
+     * Command to add a message to the message area.
+     *
+     * @param {Drupal.Ajax} [ajax]
+     *   {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+     * @param {object} response
+     *   The response from the Ajax request.
+     * @param {string} response.messageWrapperQuerySelector
+     *   The zone where to add the message. If null, the default will be used.
+     * @param {string} response.message
+     *   The message text.
+     * @param {string} response.messageOptions
+     *   The options argument for Drupal.Message().add().
+     * @param {bool} response.clearPrevious
+     *   If true, clear previous messages.
+     */
+    message(ajax, response) {
+      const messages = new Drupal.Message(
+        document.querySelector(response.messageWrapperQuerySelector),
+      );
+      if (response.clearPrevious) {
+        messages.clear();
+      }
+      messages.add(response.message, response.messageOptions);
+    },
   };
 })(jQuery, window, Drupal, drupalSettings);
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
index 85cfa0739ca03ec46f539de07f5cc713327d12a2..7df250198765172e82a667380df1cd65af37b4e1 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -639,6 +639,13 @@ function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr
           document.styleSheets[0].addImport(match[1]);
         } while (match);
       }
+    },
+    message: function message(ajax, response) {
+      var messages = new Drupal.Message(document.querySelector(response.messageWrapperQuerySelector));
+      if (response.clearPrevious) {
+        messages.clear();
+      }
+      messages.add(response.message, response.messageOptions);
     }
   };
 })(jQuery, window, Drupal, drupalSettings);
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
index 875b7caa9611354adea5796fbd96cba06eac4b54..40937fa6f9c15aad2b6f7fd31493fa6f624cd895 100644
--- a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
+++ b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
@@ -77,3 +77,11 @@ ajax_test.render_error:
     _controller: '\Drupal\ajax_test\Controller\AjaxTestController::renderError'
   requirements:
     _access: 'TRUE'
+
+ajax_test.message_form:
+  path: '/ajax-test/message'
+  defaults:
+    _title: 'Ajax Message Form'
+    _form: '\Drupal\ajax_test\Form\AjaxTestMessageCommandForm'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..6c37a09a905bd25f450627a2cb59c313d7724d65
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestMessageCommandForm.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\ajax_test\Form;
+
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\MessageCommand;
+use Drupal\Core\Form\FormInterface;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Form for testing AJAX MessageCommand.
+ *
+ * @internal
+ */
+class AjaxTestMessageCommandForm implements FormInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ajax_test_message_command_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['alternate-message-container'] = [
+      '#type' => 'container',
+      '#id' => 'alternate-message-container',
+    ];
+    $form['button_default'] = [
+      '#type' => 'submit',
+      '#name' => 'makedefaultmessage',
+      '#value' => 'Make Message In Default Location',
+      '#ajax' => [
+        'callback' => '::makeMessageDefault',
+      ],
+    ];
+    $form['button_alternate'] = [
+      '#type' => 'submit',
+      '#name' => 'makealternatemessage',
+      '#value' => 'Make Message In Alternate Location',
+      '#ajax' => [
+        'callback' => '::makeMessageAlternate',
+      ],
+    ];
+    $form['button_warning'] = [
+      '#type' => 'submit',
+      '#name' => 'makewarningmessage',
+      '#value' => 'Make Warning Message',
+      '#ajax' => [
+        'callback' => '::makeMessageWarning',
+      ],
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * Callback for testing MessageCommand with default settings.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function makeMessageDefault() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new MessageCommand('I am a message in the default location.'));
+  }
+
+  /**
+   * Callback for testing MessageCommand using an alternate message location.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function makeMessageAlternate() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new MessageCommand('I am a message in an alternate location.', '#alternate-message-container', [], FALSE));
+  }
+
+  /**
+   * Callback for testing MessageCommand with warning status.
+   *
+   * @return \Drupal\Core\Ajax\AjaxResponse
+   *   The AJAX response.
+   */
+  public function makeMessageWarning() {
+    $response = new AjaxResponse();
+    return $response->addCommand(new MessageCommand('I am a warning message in the default location.', NULL, ['type' => 'warning', 'announce' => '']));
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..22cbbab76efbbbff307d892e2e470f9e461d71e7
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/MessageCommandTest.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Ajax;
+
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+
+/**
+ * Tests adding messages via AJAX command.
+ *
+ * @group Ajax
+ */
+class MessageCommandTest extends WebDriverTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['ajax_test'];
+
+  /**
+   * Test AJAX MessageCommand use in a form.
+   */
+  public function testMessageCommand() {
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+
+    $this->drupalGet('ajax-test/message');
+    $page->pressButton('Make Message In Default Location');
+    $this->waitForMessageVisible('I am a message in the default location.');
+    $this->assertAnnounceContains('I am a message in the default location.');
+    $assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
+
+    $page->pressButton('Make Message In Alternate Location');
+    $this->waitForMessageVisible('I am a message in an alternate location.', '#alternate-message-container');
+    $assert_session->pageTextContains('I am a message in the default location.');
+    $this->assertAnnounceContains('I am a message in an alternate location.');
+    $assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
+    $assert_session->elementsCount('css', '#alternate-message-container .messages', 1);
+
+    $page->pressButton('Make Warning Message');
+    $this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning');
+    $assert_session->pageTextNotContains('I am a message in the default location.');
+    $assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
+    $assert_session->elementsCount('css', '#alternate-message-container .messages', 1);
+
+    $this->drupalGet('ajax-test/message');
+    // Test that by default, previous messages in a location are removed.
+    for ($i = 0; $i < 6; $i++) {
+      $page->pressButton('Make Message In Default Location');
+      $this->waitForMessageVisible('I am a message in the default location.');
+      $assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
+
+      $page->pressButton('Make Warning Message');
+      $this->waitForMessageVisible('I am a warning message in the default location.', NULL, 'warning');
+      // Test that setting MessageCommand::$option['announce'] => '' supresses
+      // screen reader announcement.
+      $this->assertAnnounceNotContains('I am a warning message in the default location.');
+      $this->waitForMessageRemoved('I am a message in the default location.');
+      $assert_session->elementsCount('css', '.messages__wrapper .messages', 1);
+    }
+
+    // Test that if MessageCommand::clearPrevious is FALSE, messages will not
+    // be cleared.
+    $this->drupalGet('ajax-test/message');
+    for ($i = 1; $i < 7; $i++) {
+      $page->pressButton('Make Message In Alternate Location');
+      $expected_count = $page->waitFor(10, function () use ($i, $page) {
+        return count($page->findAll('css', '#alternate-message-container .messages')) === $i;
+      });
+      $this->assertTrue($expected_count);
+      $this->assertAnnounceContains('I am a message in an alternate location.');
+    }
+  }
+
+  /**
+   * Asserts that a message of the expected type appears.
+   *
+   * @param string $message
+   *   The expected message.
+   * @param string $selector
+   *   The selector for the element in which to check for the expected message.
+   * @param string $type
+   *   The expected type.
+   */
+  protected function waitForMessageVisible($message, $selector = '[data-drupal-messages]', $type = 'status') {
+    $this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")'));
+  }
+
+  /**
+   * Asserts that a message of the expected type is removed.
+   *
+   * @param string $message
+   *   The expected message.
+   * @param string $selector
+   *   The selector for the element in which to check for the expected message.
+   * @param string $type
+   *   The expected type.
+   */
+  protected function waitForMessageRemoved($message, $selector = '[data-drupal-messages]', $type = 'status') {
+    $this->assertNotEmpty($this->assertSession()->waitForElementRemoved('css', $selector . ' .messages--' . $type . ':contains("' . $message . '")'));
+  }
+
+  /**
+   * Checks for inclusion of text in #drupal-live-announce.
+   *
+   * @param string $expected_message
+   *   The text expected to be present in #drupal-live-announce.
+   */
+  protected function assertAnnounceContains($expected_message) {
+    $assert_session = $this->assertSession();
+    $this->assertNotEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')"));
+  }
+
+  /**
+   * Checks for absence of the given text from #drupal-live-announce.
+   *
+   * @param string $expected_message
+   *   The text expected to be absent from #drupal-live-announce.
+   */
+  protected function assertAnnounceNotContains($expected_message) {
+    $assert_session = $this->assertSession();
+    $this->assertEmpty($assert_session->waitForElement('css', "#drupal-live-announce:contains('$expected_message')", 1000));
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
index 9aeb4b64986aa380b8a1fdbc0e427b58a99d6080..89c0b1ec024eb3fd78f6105bf5c599c7e2d528e1 100644
--- a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
@@ -6,7 +6,7 @@
 use Drupal\js_message_test\Controller\JSMessageTestController;
 
 /**
- * Tests core/drupal.messages library.
+ * Tests core/drupal.message library.
  *
  * @group Javascript
  */