diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 918a36afd309cc9772aedb0c1f6b33cf1d56c186..86879cfdee29485337a266c1d2efa786e6fb40ee 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -233,6 +233,14 @@ drupal.machine-name:
     - core/drupalSettings
     - core/drupal.form
 
+drupal.message:
+  version: VERSION
+  js:
+    misc/message.js: {}
+  dependencies:
+    - core/drupal
+    - core/drupal.announce
+
 drupal.progress:
   version: VERSION
   js:
diff --git a/core/lib/Drupal/Core/Render/Element/StatusMessages.php b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
index d8627343eea01bb766de507d518bd0c139492fa7..e02c91aef0cc132db09200a441e61b9ba6e61c8d 100644
--- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php
+++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
@@ -32,6 +32,7 @@ public function getInfo() {
       '#pre_render' => [
         get_class() . '::generatePlaceholder',
       ],
+      '#include_fallback' => FALSE,
     ];
   }
 
@@ -45,7 +46,7 @@ public function getInfo() {
    *   The updated renderable array containing the placeholder.
    */
   public static function generatePlaceholder(array $element) {
-    $element = [
+    $build = [
       '#lazy_builder' => [get_class() . '::renderMessages', [$element['#display']]],
       '#create_placeholder' => TRUE,
     ];
@@ -53,7 +54,17 @@ public static function generatePlaceholder(array $element) {
     // Directly create a placeholder as we need this to be placeholdered
     // regardless if this is a POST or GET request.
     // @todo remove this when https://www.drupal.org/node/2367555 lands.
-    return \Drupal::service('render_placeholder_generator')->createPlaceholder($element);
+    $build = \Drupal::service('render_placeholder_generator')->createPlaceholder($build);
+
+    if ($element['#include_fallback']) {
+      return [
+        'fallback' => [
+          '#markup' => '<div data-drupal-messages-fallback class="hidden"></div>',
+        ],
+        'messages' => $build,
+      ];
+    }
+    return $build;
   }
 
   /**
diff --git a/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php b/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php
index 9296d1d1e7a9511f3af4530da3c407a040838161..7dc930695d0f302863b7a39447e409b0d055876b 100644
--- a/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php
+++ b/core/lib/Drupal/Core/Render/Plugin/DisplayVariant/SimplePageVariant.php
@@ -54,6 +54,7 @@ public function build() {
         'messages' => [
           '#type' => 'status_messages',
           '#weight' => -1000,
+          '#include_fallback' => TRUE,
         ],
         'page_title' => [
           '#type' => 'page_title',
diff --git a/core/misc/message.es6.js b/core/misc/message.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..252d629310fb6462988e7efea0141ab8ea0de4dc
--- /dev/null
+++ b/core/misc/message.es6.js
@@ -0,0 +1,256 @@
+/**
+ * @file
+ * Message API.
+ */
+(Drupal => {
+  /**
+   * @typedef {class} Drupal.Message~messageDefinition
+   */
+
+  /**
+   * Constructs a new instance of the Drupal.Message class.
+   *
+   * This provides a uniform interface for adding and removing messages to a
+   * specific location on the page.
+   *
+   * @param {HTMLElement} messageWrapper
+   *   The zone where to add messages. If no element is provided an attempt is
+   *   made to determine a default location.
+   *
+   * @return {Drupal.Message~messageDefinition}
+   *   Class to add and remove messages.
+   */
+  Drupal.Message = class {
+    constructor(messageWrapper = null) {
+      this.messageWrapper = messageWrapper;
+    }
+
+    /**
+     * Attempt to determine the default location for
+     * inserting JavaScript messages or create one if needed.
+     *
+     * @return {HTMLElement}
+     *   The default destination for JavaScript messages.
+     */
+    static defaultWrapper() {
+      let wrapper = document.querySelector('[data-drupal-messages]');
+      if (!wrapper) {
+        wrapper = document.querySelector('[data-drupal-messages-fallback]');
+        wrapper.removeAttribute('data-drupal-messages-fallback');
+        wrapper.setAttribute('data-drupal-messages', '');
+        wrapper.removeAttribute('class');
+      }
+      return wrapper.innerHTML === ''
+        ? Drupal.Message.messageInternalWrapper(wrapper)
+        : wrapper.firstElementChild;
+    }
+
+    /**
+     * Provide an object containing the available message types.
+     *
+     * @return {Object}
+     *   An object containing message type strings.
+     */
+    static getMessageTypeLabels() {
+      return {
+        status: Drupal.t('Status message'),
+        error: Drupal.t('Error message'),
+        warning: Drupal.t('Warning message'),
+      };
+    }
+
+    /**
+     * Sequentially adds a message to the message area.
+     *
+     * @name Drupal.Message~messageDefinition.add
+     *
+     * @param {string} message
+     *   The message to display
+     * @param {object} [options]
+     *   The context of the message.
+     * @param {string} [options.id]
+     *   The message ID, it can be a simple value: `'filevalidationerror'`
+     *   or several values separated by a space: `'mymodule formvalidation'`
+     *   which can be used as an explicit selector for a message.
+     * @param {string} [options.type=status]
+     *   Message type, can be either 'status', 'error' or 'warning'.
+     * @param {string} [options.announce]
+     *   Screen-reader version of the message if necessary. To prevent a message
+     *   being sent to Drupal.announce() this should be an emptry string.
+     * @param {string} [options.priority]
+     *   Priority of the message for Drupal.announce().
+     *
+     * @return {string}
+     *   ID of message.
+     */
+    add(message, options = {}) {
+      if (!this.messageWrapper) {
+        this.messageWrapper = Drupal.Message.defaultWrapper();
+      }
+      if (!options.hasOwnProperty('type')) {
+        options.type = 'status';
+      }
+
+      if (typeof message !== 'string') {
+        throw new Error('Message must be a string.');
+      }
+
+      // Send message to screen reader.
+      Drupal.Message.announce(message, options);
+      /**
+       * Use the provided index for the message or generate a pseudo-random key
+       * to allow message deletion.
+       */
+      options.id = options.id
+        ? String(options.id)
+        : `${options.type}-${Math.random()
+            .toFixed(15)
+            .replace('0.', '')}`;
+
+      // Throw an error if an unexpected message type is used.
+      if (!Drupal.Message.getMessageTypeLabels().hasOwnProperty(options.type)) {
+        throw new Error(
+          `The message type, ${
+            options.type
+          }, is not present in Drupal.Message.getMessageTypeLabels().`,
+        );
+      }
+
+      this.messageWrapper.appendChild(
+        Drupal.theme('message', { text: message }, options),
+      );
+
+      return options.id;
+    }
+
+    /**
+     * Select a message based on id.
+     *
+     * @name Drupal.Message~messageDefinition.select
+     *
+     * @param {string} id
+     *   The message id to delete from the area.
+     *
+     * @return {Element}
+     *   Element found.
+     */
+    select(id) {
+      return this.messageWrapper.querySelector(
+        `[data-drupal-message-id^="${id}"]`,
+      );
+    }
+
+    /**
+     * Removes messages from the message area.
+     *
+     * @name Drupal.Message~messageDefinition.remove
+     *
+     * @param {string} id
+     *   Index of the message to remove, as returned by
+     *   {@link Drupal.Message~messageDefinition.add}.
+     *
+     * @return {number}
+     *   Number of removed messages.
+     */
+    remove(id) {
+      return this.messageWrapper.removeChild(this.select(id));
+    }
+
+    /**
+     * Removes all messages from the message area.
+     *
+     * @name Drupal.Message~messageDefinition.clear
+     */
+    clear() {
+      Array.prototype.forEach.call(
+        this.messageWrapper.querySelectorAll('[data-drupal-message-id]'),
+        message => {
+          this.messageWrapper.removeChild(message);
+        },
+      );
+    }
+
+    /**
+     * Helper to call Drupal.announce() with the right parameters.
+     *
+     * @param {string} message
+     *   Displayed message.
+     * @param {object} options
+     *   Additional data.
+     * @param {string} [options.announce]
+     *   Screen-reader version of the message if necessary. To prevent a message
+     *   being sent to Drupal.announce() this should be `''`.
+     * @param {string} [options.priority]
+     *   Priority of the message for Drupal.announce().
+     * @param {string} [options.type]
+     *   Message type, can be either 'status', 'error' or 'warning'.
+     */
+    static announce(message, options) {
+      if (
+        !options.priority &&
+        (options.type === 'warning' || options.type === 'error')
+      ) {
+        options.priority = 'assertive';
+      }
+      /**
+       * If screen reader message is not disabled announce screen reader
+       * specific text or fallback to the displayed message.
+       */
+      if (options.announce !== '') {
+        Drupal.announce(options.announce || message, options.priority);
+      }
+    }
+
+    /**
+     * Function for creating the internal message wrapper element.
+     *
+     * @param {HTMLElement} messageWrapper
+     *   The message wrapper.
+     *
+     * @return {HTMLElement}
+     *   The internal wrapper DOM element.
+     */
+    static messageInternalWrapper(messageWrapper) {
+      const innerWrapper = document.createElement('div');
+      innerWrapper.setAttribute('class', 'messages__wrapper');
+      messageWrapper.insertAdjacentElement('afterbegin', innerWrapper);
+      return innerWrapper;
+    }
+  };
+
+  /**
+   * Theme function for a message.
+   *
+   * @param {object} message
+   *   The message object.
+   * @param {string} message.text
+   *   The message text.
+   * @param {object} options
+   *   The message context.
+   * @param {string} options.type
+   *   The message type.
+   * @param {string} options.id
+   *   ID of the message, for reference.
+   *
+   * @return {HTMLElement}
+   *   A DOM Node.
+   */
+  Drupal.theme.message = ({ text }, { type, id }) => {
+    const messagesTypes = Drupal.Message.getMessageTypeLabels();
+    const messageWrapper = document.createElement('div');
+
+    messageWrapper.setAttribute('class', `messages messages--${type}`);
+    messageWrapper.setAttribute(
+      'role',
+      type === 'error' || type === 'warning' ? 'alert' : 'status',
+    );
+    messageWrapper.setAttribute('data-drupal-message-id', id);
+    messageWrapper.setAttribute('data-drupal-message-type', type);
+
+    messageWrapper.setAttribute('aria-label', messagesTypes[type]);
+
+    messageWrapper.innerHTML = `${text}`;
+
+    return messageWrapper;
+  };
+})(Drupal);
diff --git a/core/misc/message.js b/core/misc/message.js
new file mode 100644
index 0000000000000000000000000000000000000000..38bc7b3a3af62066729ff1a45b0ab1c8c6d3dca5
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,132 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+(function (Drupal) {
+  Drupal.Message = function () {
+    function _class() {
+      var messageWrapper = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
+
+      _classCallCheck(this, _class);
+
+      this.messageWrapper = messageWrapper;
+    }
+
+    _createClass(_class, [{
+      key: 'add',
+      value: function add(message) {
+        var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+
+        if (!this.messageWrapper) {
+          this.messageWrapper = Drupal.Message.defaultWrapper();
+        }
+        if (!options.hasOwnProperty('type')) {
+          options.type = 'status';
+        }
+
+        if (typeof message !== 'string') {
+          throw new Error('Message must be a string.');
+        }
+
+        Drupal.Message.announce(message, options);
+
+        options.id = options.id ? String(options.id) : options.type + '-' + Math.random().toFixed(15).replace('0.', '');
+
+        if (!Drupal.Message.getMessageTypeLabels().hasOwnProperty(options.type)) {
+          throw new Error('The message type, ' + options.type + ', is not present in Drupal.Message.getMessageTypeLabels().');
+        }
+
+        this.messageWrapper.appendChild(Drupal.theme('message', { text: message }, options));
+
+        return options.id;
+      }
+    }, {
+      key: 'select',
+      value: function select(id) {
+        return this.messageWrapper.querySelector('[data-drupal-message-id^="' + id + '"]');
+      }
+    }, {
+      key: 'remove',
+      value: function remove(id) {
+        return this.messageWrapper.removeChild(this.select(id));
+      }
+    }, {
+      key: 'clear',
+      value: function clear() {
+        var _this = this;
+
+        Array.prototype.forEach.call(this.messageWrapper.querySelectorAll('[data-drupal-message-id]'), function (message) {
+          _this.messageWrapper.removeChild(message);
+        });
+      }
+    }], [{
+      key: 'defaultWrapper',
+      value: function defaultWrapper() {
+        var wrapper = document.querySelector('[data-drupal-messages]');
+        if (!wrapper) {
+          wrapper = document.querySelector('[data-drupal-messages-fallback]');
+          wrapper.removeAttribute('data-drupal-messages-fallback');
+          wrapper.setAttribute('data-drupal-messages', '');
+          wrapper.removeAttribute('class');
+        }
+        return wrapper.innerHTML === '' ? Drupal.Message.messageInternalWrapper(wrapper) : wrapper.firstElementChild;
+      }
+    }, {
+      key: 'getMessageTypeLabels',
+      value: function getMessageTypeLabels() {
+        return {
+          status: Drupal.t('Status message'),
+          error: Drupal.t('Error message'),
+          warning: Drupal.t('Warning message')
+        };
+      }
+    }, {
+      key: 'announce',
+      value: function announce(message, options) {
+        if (!options.priority && (options.type === 'warning' || options.type === 'error')) {
+          options.priority = 'assertive';
+        }
+
+        if (options.announce !== '') {
+          Drupal.announce(options.announce || message, options.priority);
+        }
+      }
+    }, {
+      key: 'messageInternalWrapper',
+      value: function messageInternalWrapper(messageWrapper) {
+        var innerWrapper = document.createElement('div');
+        innerWrapper.setAttribute('class', 'messages__wrapper');
+        messageWrapper.insertAdjacentElement('afterbegin', innerWrapper);
+        return innerWrapper;
+      }
+    }]);
+
+    return _class;
+  }();
+
+  Drupal.theme.message = function (_ref, _ref2) {
+    var text = _ref.text;
+    var type = _ref2.type,
+        id = _ref2.id;
+
+    var messagesTypes = Drupal.Message.getMessageTypeLabels();
+    var messageWrapper = document.createElement('div');
+
+    messageWrapper.setAttribute('class', 'messages messages--' + type);
+    messageWrapper.setAttribute('role', type === 'error' || type === 'warning' ? 'alert' : 'status');
+    messageWrapper.setAttribute('data-drupal-message-id', id);
+    messageWrapper.setAttribute('data-drupal-message-type', type);
+
+    messageWrapper.setAttribute('aria-label', messagesTypes[type]);
+
+    messageWrapper.innerHTML = '' + text;
+
+    return messageWrapper;
+  };
+})(Drupal);
\ No newline at end of file
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php
index f274356139da1eac955f5e3ac31aa187db71d204..d1ba348e12062983bd6922a6ef64fc73abbd333b 100644
--- a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipePlaceholderTestCases.php
@@ -88,11 +88,11 @@ public static function cases(ContainerInterface $container = NULL, AccountInterf
           'command' => 'insert',
           'method' => 'replaceWith',
           'selector' => '[data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args%5B0%5D&token=_HAdUpwWmet0TOTe2PSiJuMntExoshbm1kh2wQzzzAA"]',
-          'data' => ' <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n ",
+          'data' => '<div data-drupal-messages>' . "\n" . ' <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . ' <h2 class="visually-hidden">Status message</h2>' . "\n" . ' Hello from BigPipe!' . "\n" . ' </div>' . "\n" . ' </div>' . "\n",
           'settings' => NULL,
         ],
       ];
-      $status_messages->embeddedHtmlResponse = '<div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n    \n";
+      $status_messages->embeddedHtmlResponse = '<div data-drupal-messages-fallback class="hidden"></div><div data-drupal-messages>' . "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n" . '    </div>' . "\n";
     }
 
     // 2. Real-world example of HTML attribute value placeholder: form action.
diff --git a/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php b/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php
index 2dad5e6a344441b20f57aeed94f5c4d99885e1c1..e95ccd2c7b4078b603baf1ceab559547b5ecebaf 100644
--- a/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php
+++ b/core/modules/block/src/Plugin/DisplayVariant/BlockPageVariant.php
@@ -177,6 +177,7 @@ public function build() {
       $build['content']['messages'] = [
         '#weight' => -1000,
         '#type' => 'status_messages',
+        '#include_fallback' => TRUE,
       ];
     }
 
diff --git a/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php b/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php
index 5d3a20b6a6d4cad83dc13dd8f5939e357a908aed..a5e2e72e846b8aa5a95ccd7d66eaf50bd6ba6310 100644
--- a/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php
+++ b/core/modules/block/tests/src/Unit/Plugin/DisplayVariant/BlockPageVariantTest.php
@@ -153,6 +153,7 @@ public function providerBuild() {
           'messages' => [
             '#weight' => -1000,
             '#type' => 'status_messages',
+            '#include_fallback' => TRUE,
           ],
         ],
       ],
@@ -185,6 +186,7 @@ public function providerBuild() {
           'messages' => [
             '#weight' => -1000,
             '#type' => 'status_messages',
+            '#include_fallback' => TRUE,
           ],
         ],
       ],
@@ -253,6 +255,7 @@ public function testBuildWithoutMainContent() {
         'messages' => [
           '#weight' => -1000,
           '#type' => 'status_messages',
+          '#include_fallback' => TRUE,
         ],
       ],
     ];
diff --git a/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php b/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
index 99901957e761e66271548a06a0ec7e18e972429e..f737b8fd5db6ce603afe0aae3862b7e51094b35c 100644
--- a/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
+++ b/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
@@ -31,7 +31,10 @@ public function defaultConfiguration() {
    * {@inheritdoc}
    */
   public function build() {
-    return ['#type' => 'status_messages'];
+    return [
+      '#type' => 'status_messages',
+      '#include_fallback' => TRUE,
+    ];
   }
 
   /**
diff --git a/core/modules/system/src/Tests/JsMessageTestCases.php b/core/modules/system/src/Tests/JsMessageTestCases.php
new file mode 100644
index 0000000000000000000000000000000000000000..05e1040dd341b8d9b22f881b0d04d16780f42e51
--- /dev/null
+++ b/core/modules/system/src/Tests/JsMessageTestCases.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\system\Tests;
+
+/**
+ * Test cases for JS Messages tests.
+ */
+class JsMessageTestCases {
+
+  /**
+   * Gets the test types.
+   *
+   * @return string[]
+   *   The test types.
+   */
+  public static function getTypes() {
+    return ['status', 'error', 'warning'];
+  }
+
+  /**
+   * Gets the test messages selectors.
+   *
+   * @return string[]
+   *   The test test messages selectors.
+   *
+   * @see core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig
+   */
+  public static function getMessagesSelectors() {
+    return ['', '[data-drupal-messages-other]'];
+  }
+
+}
diff --git a/core/modules/system/templates/status-messages.html.twig b/core/modules/system/templates/status-messages.html.twig
index 6ad802e1436610fd165a7bebc3b2748fb58196e9..1385bbb7346814af7532513d96870e62fb5240e1 100644
--- a/core/modules/system/templates/status-messages.html.twig
+++ b/core/modules/system/templates/status-messages.html.twig
@@ -21,25 +21,27 @@
  * @ingroup themeable
  */
 #}
+<div data-drupal-messages>
 {% for type, messages in message_list %}
   <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes|without('role', 'aria-label') }}>
     {% if type == 'error' %}
       <div role="alert">
     {% endif %}
-      {% if status_headings[type] %}
-        <h2 class="visually-hidden">{{ status_headings[type] }}</h2>
-      {% endif %}
-      {% if messages|length > 1 %}
-        <ul>
-          {% for message in messages %}
-            <li>{{ message }}</li>
-          {% endfor %}
-        </ul>
-      {% else %}
-        {{ messages|first }}
-      {% endif %}
+    {% if status_headings[type] %}
+      <h2 class="visually-hidden">{{ status_headings[type] }}</h2>
+    {% endif %}
+    {% if messages|length > 1 %}
+      <ul>
+        {% for message in messages %}
+          <li>{{ message }}</li>
+        {% endfor %}
+      </ul>
+    {% else %}
+      {{ messages|first }}
+    {% endif %}
     {% if type == 'error' %}
       </div>
     {% endif %}
   </div>
 {% endfor %}
+</div>
diff --git a/core/modules/system/tests/modules/js_message_test/js/js_message_test.es6.js b/core/modules/system/tests/modules/js_message_test/js/js_message_test.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..08d250a335685b19015bab9c9fac8592ffe45f2c
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.es6.js
@@ -0,0 +1,122 @@
+/**
+ * @file
+ *  Testing behavior for JSMessageTest.
+ */
+
+(($, { behaviors }, { testMessages }) => {
+  // Message types.
+  const indexes = {};
+  testMessages.types.forEach(type => {
+    indexes[type] = [];
+  });
+
+  // Message storage.
+  const messageObjects = {
+    default: {
+      zone: new Drupal.Message(),
+      indexes,
+    },
+    multiple: [],
+  };
+
+  testMessages.selectors.filter(Boolean).forEach(selector => {
+    messageObjects[selector] = {
+      zone: new Drupal.Message(document.querySelector(selector)),
+      indexes,
+    };
+  });
+
+  /**
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Add click listeners that show and remove links with context and type.
+   */
+  behaviors.js_message_test = {
+    attach() {
+      $('[data-drupal-messages-area]')
+        .once('messages-details')
+        .on('click', '[data-action]', e => {
+          const $target = $(e.currentTarget);
+          const type = $target.attr('data-type');
+          const area =
+            $target
+              .closest('[data-drupal-messages-area]')
+              .attr('data-drupal-messages-area') || 'default';
+          const message = messageObjects[area].zone;
+          const action = $target.attr('data-action');
+
+          if (action === 'add') {
+            messageObjects[area].indexes[type].push(
+              message.add(
+                `This is a message of the type, ${type}. You be the the judge of its importance.`,
+                { type },
+              ),
+            );
+          } else if (action === 'remove') {
+            message.remove(messageObjects[area].indexes[type].pop());
+          }
+        });
+      $('[data-action="add-multiple"]')
+        .once('add-multiple')
+        .on('click', () => {
+          /**
+           * Add several of different types to make sure message type doesn't
+           * cause issues in the API.
+           */
+          [0, 1, 2, 3, 4, 5].forEach(i => {
+            messageObjects.multiple.push(
+              messageObjects.default.zone.add(
+                `This is message number ${i} of the type, ${
+                  testMessages.types[i % testMessages.types.length]
+                }. You be the the judge of its importance.`,
+                { type: testMessages.types[i % testMessages.types.length] },
+              ),
+            );
+          });
+        });
+      $('[data-action="remove-multiple"]')
+        .once('remove-multiple')
+        .on('click', () => {
+          messageObjects.multiple.forEach(messageIndex =>
+            messageObjects.default.zone.remove(messageIndex),
+          );
+          messageObjects.multiple = [];
+        });
+      $('[data-action="add-multiple-error"]')
+        .once('add-multiple-error')
+        .on('click', () => {
+          // Use the same number of elements to facilitate things on the PHP side.
+          [0, 1, 2, 3, 4, 5].forEach(i =>
+            messageObjects.default.zone.add(`Msg-${i}`, { type: 'error' }),
+          );
+          messageObjects.default.zone.add(
+            `Msg-${testMessages.types.length * 2}`,
+            { type: 'status' },
+          );
+        });
+      $('[data-action="remove-type"]')
+        .once('remove-type')
+        .on('click', () => {
+          Array.prototype.map
+            .call(
+              document.querySelectorAll('[data-drupal-message-id^="error"]'),
+              element => element.getAttribute('data-drupal-message-id'),
+            )
+            .forEach(id => messageObjects.default.zone.remove(id));
+        });
+      $('[data-action="clear-all"]')
+        .once('clear-all')
+        .on('click', () => {
+          messageObjects.default.zone.clear();
+        });
+      $('[data-action="id-no-status"]')
+        .once('id-no-status')
+        .on('click', () => {
+          messageObjects.default.zone.add('Msg-id-no-status', {
+            id: 'my-special-id',
+          });
+        });
+    },
+  };
+})(jQuery, Drupal, drupalSettings);
diff --git a/core/modules/system/tests/modules/js_message_test/js/js_message_test.js b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..33c49799911932b89280e7b5ab51ef4543681e35
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
@@ -0,0 +1,81 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function ($, _ref, _ref2) {
+  var behaviors = _ref.behaviors;
+  var testMessages = _ref2.testMessages;
+
+  var indexes = {};
+  testMessages.types.forEach(function (type) {
+    indexes[type] = [];
+  });
+
+  var messageObjects = {
+    default: {
+      zone: new Drupal.Message(),
+      indexes: indexes
+    },
+    multiple: []
+  };
+
+  testMessages.selectors.filter(Boolean).forEach(function (selector) {
+    messageObjects[selector] = {
+      zone: new Drupal.Message(document.querySelector(selector)),
+      indexes: indexes
+    };
+  });
+
+  behaviors.js_message_test = {
+    attach: function attach() {
+      $('[data-drupal-messages-area]').once('messages-details').on('click', '[data-action]', function (e) {
+        var $target = $(e.currentTarget);
+        var type = $target.attr('data-type');
+        var area = $target.closest('[data-drupal-messages-area]').attr('data-drupal-messages-area') || 'default';
+        var message = messageObjects[area].zone;
+        var action = $target.attr('data-action');
+
+        if (action === 'add') {
+          messageObjects[area].indexes[type].push(message.add('This is a message of the type, ' + type + '. You be the the judge of its importance.', { type: type }));
+        } else if (action === 'remove') {
+          message.remove(messageObjects[area].indexes[type].pop());
+        }
+      });
+      $('[data-action="add-multiple"]').once('add-multiple').on('click', function () {
+        [0, 1, 2, 3, 4, 5].forEach(function (i) {
+          messageObjects.multiple.push(messageObjects.default.zone.add('This is message number ' + i + ' of the type, ' + testMessages.types[i % testMessages.types.length] + '. You be the the judge of its importance.', { type: testMessages.types[i % testMessages.types.length] }));
+        });
+      });
+      $('[data-action="remove-multiple"]').once('remove-multiple').on('click', function () {
+        messageObjects.multiple.forEach(function (messageIndex) {
+          return messageObjects.default.zone.remove(messageIndex);
+        });
+        messageObjects.multiple = [];
+      });
+      $('[data-action="add-multiple-error"]').once('add-multiple-error').on('click', function () {
+        [0, 1, 2, 3, 4, 5].forEach(function (i) {
+          return messageObjects.default.zone.add('Msg-' + i, { type: 'error' });
+        });
+        messageObjects.default.zone.add('Msg-' + testMessages.types.length * 2, { type: 'status' });
+      });
+      $('[data-action="remove-type"]').once('remove-type').on('click', function () {
+        Array.prototype.map.call(document.querySelectorAll('[data-drupal-message-id^="error"]'), function (element) {
+          return element.getAttribute('data-drupal-message-id');
+        }).forEach(function (id) {
+          return messageObjects.default.zone.remove(id);
+        });
+      });
+      $('[data-action="clear-all"]').once('clear-all').on('click', function () {
+        messageObjects.default.zone.clear();
+      });
+      $('[data-action="id-no-status"]').once('id-no-status').on('click', function () {
+        messageObjects.default.zone.add('Msg-id-no-status', {
+          id: 'my-special-id'
+        });
+      });
+    }
+  };
+})(jQuery, Drupal, drupalSettings);
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e8bc73b06598ed80e5d07ef20314f5fc7b705b41
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml
@@ -0,0 +1,6 @@
+name: 'JS Message test module'
+type: module
+description: 'Module for the JSMessageTest test.'
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml
new file mode 100644
index 0000000000000000000000000000000000000000..57501a9ce034cf96ef71ba4ed1081fc4d9c7f078
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml
@@ -0,0 +1,8 @@
+show_message:
+  version: VERSION
+  js:
+    js/js_message_test.js: {}
+  dependencies:
+    - core/drupalSettings
+    - core/drupal.message
+    - core/jquery.once
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4c325e84611ec4018ee5c5eb8dbc507132d304f2
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml
@@ -0,0 +1,7 @@
+js_message_test.links:
+  path: '/js_message_test_link'
+  defaults:
+    _controller: '\Drupal\js_message_test\Controller\JSMessageTestController::messageLinks'
+    _title: 'JsMessageLinks'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
new file mode 100644
index 0000000000000000000000000000000000000000..57d38284f7346a6dc27aedbf85c4da8d107ef097
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Drupal\js_message_test\Controller;
+
+use Drupal\system\Tests\JsMessageTestCases;
+
+/**
+ * Test Controller to show message links.
+ */
+class JSMessageTestController {
+
+  /**
+   * Displays links to show messages via Javascript.
+   *
+   * @return array
+   *   Render array for links.
+   */
+  public function messageLinks() {
+    $buttons = [];
+    foreach (JsMessageTestCases::getMessagesSelectors() as $messagesSelector) {
+      $buttons[$messagesSelector] = [
+        '#type' => 'details',
+        '#open' => TRUE,
+        '#title' => "Message area: $messagesSelector",
+        '#attributes' => [
+          'data-drupal-messages-area' => $messagesSelector,
+        ],
+      ];
+      foreach (JsMessageTestCases::getTypes() as $type) {
+        $buttons[$messagesSelector]["add-$type"] = [
+          '#type' => 'html_tag',
+          '#tag' => 'button',
+          '#value' => "Add $type",
+          '#attributes' => [
+            'type' => 'button',
+            'id' => "add-$messagesSelector-$type",
+            'data-type' => $type,
+            'data-action' => 'add',
+          ],
+        ];
+        $buttons[$messagesSelector]["remove-$type"] = [
+          '#type' => 'html_tag',
+          '#tag' => 'button',
+          '#value' => "Remove $type",
+          '#attributes' => [
+            'type' => 'button',
+            'id' => "remove-$messagesSelector-$type",
+            'data-type' => $type,
+            'data-action' => 'remove',
+          ],
+        ];
+      }
+    }
+    // Add alternative message area.
+    $buttons[JsMessageTestCases::getMessagesSelectors()[1]]['messages-other-area'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'div',
+      '#attributes' => [
+        'data-drupal-messages-other' => TRUE,
+      ],
+    ];
+    $buttons['add-multiple'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'button',
+      '#value' => "Add multiple",
+      '#attributes' => [
+        'type' => 'button',
+        'id' => 'add-multiple',
+        'data-action' => 'add-multiple',
+      ],
+    ];
+    $buttons['remove-multiple'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'button',
+      '#value' => "Remove multiple",
+      '#attributes' => [
+        'type' => 'button',
+        'id' => 'remove-multiple',
+        'data-action' => 'remove-multiple',
+      ],
+    ];
+    $buttons['add-multiple-error'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'button',
+      '#value' => "Add multiple 'error' and one 'status'",
+      '#attributes' => [
+        'type' => 'button',
+        'id' => 'add-multiple-error',
+        'data-action' => 'add-multiple-error',
+      ],
+    ];
+    $buttons['remove-type'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'button',
+      '#value' => "Remove 'error' type",
+      '#attributes' => [
+        'type' => 'button',
+        'id' => 'remove-type',
+        'data-action' => 'remove-type',
+      ],
+    ];
+    $buttons['clear-all'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'button',
+      '#value' => "Clear all",
+      '#attributes' => [
+        'type' => 'button',
+        'id' => 'clear-all',
+        'data-action' => 'clear-all',
+      ],
+    ];
+
+    $buttons['id-no-status'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'button',
+      '#value' => "Id no status",
+      '#attributes' => [
+        'type' => 'button',
+        'id' => 'id-no-status',
+        'data-action' => 'id-no-status',
+      ],
+    ];
+
+    return $buttons + [
+      '#attached' => [
+        'library' => [
+          'js_message_test/show_message',
+        ],
+        'drupalSettings' => [
+          'testMessages' => [
+            'selectors' => JsMessageTestCases::getMessagesSelectors(),
+            'types' => JsMessageTestCases::getTypes(),
+          ],
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig b/core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..42f105d3df855f4dbf1637f4c1c54d261e0df083
--- /dev/null
+++ b/core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig
@@ -0,0 +1,41 @@
+{#
+/**
+ * @file
+ * Test templates file with extra messages div.
+ */
+#}
+<div data-drupal-messages>
+{% block messages %}
+{% for type, messages in message_list %}
+  {%
+    set classes = [
+      'messages',
+      'messages--' ~ type,
+    ]
+  %}
+  <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes.addClass(classes)|without('role', 'aria-label') }}>
+    {% if type == 'error' %}
+      <div role="alert">
+    {% endif %}
+      {% if status_headings[type] %}
+        <h2 class="visually-hidden">{{ status_headings[type] }}</h2>
+      {% endif %}
+      {% if messages|length > 1 %}
+        <ul class="messages__list">
+          {% for message in messages %}
+            <li class="messages__item">{{ message }}</li>
+          {% endfor %}
+        </ul>
+      {% else %}
+        {{ messages|first }}
+      {% endif %}
+    {% if type == 'error' %}
+      </div>
+    {% endif %}
+  </div>
+  {# Remove type specific classes. #}
+  {% set attributes = attributes.removeClass(classes) %}
+{% endfor %}
+{% endblock messages %}
+</div>
+<div data-drupal-messages-other></div>
diff --git a/core/modules/system/tests/themes/test_messages/test_messages.info.yml b/core/modules/system/tests/themes/test_messages/test_messages.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ea88438d98ea15af32a38026296b136410cb8525
--- /dev/null
+++ b/core/modules/system/tests/themes/test_messages/test_messages.info.yml
@@ -0,0 +1,6 @@
+name: 'Theme test messages'
+type: theme
+description: 'Test theme which provides another div for messages.'
+version: VERSION
+core: 8.x
+base theme: classy
diff --git a/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig
index a5094701b4b41671d4e9416aff220336fbf00e54..1a65511a3d5d107beaed66cb686aa26544f15758 100644
--- a/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig
+++ b/core/profiles/demo_umami/themes/umami/templates/components/messages/status-messages.html.twig
@@ -19,6 +19,7 @@
  *   - class: HTML classes.
  */
 #}
+<div data-drupal-messages>
 {% block messages %}
 {% for type, messages in message_list %}
   {%
@@ -53,3 +54,4 @@
   {% set attributes = attributes.removeClass(classes) %}
 {% endfor %}
 {% endblock messages %}
+</div>
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9b3a9548ac0a78f7a0b7ed393d264df0c7e1c208
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Core;
+
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+use Drupal\system\Tests\JsMessageTestCases;
+
+/**
+ * Tests core/drupal.messages library.
+ *
+ * @group Javascript
+ */
+class JsMessageTest extends WebDriverTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['js_message_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Enable the theme.
+    \Drupal::service('theme_installer')->install(['test_messages']);
+    $theme_config = \Drupal::configFactory()->getEditable('system.theme');
+    $theme_config->set('default', 'test_messages');
+    $theme_config->save();
+  }
+
+  /**
+   * Test click on links to show messages and remove messages.
+   */
+  public function testAddRemoveMessages() {
+    $web_assert = $this->assertSession();
+    $this->drupalGet('js_message_test_link');
+
+    $current_messages = [];
+    foreach (JsMessageTestCases::getMessagesSelectors() as $messagesSelector) {
+      $web_assert->elementExists('css', $messagesSelector);
+      foreach (JsMessageTestCases::getTypes() as $type) {
+        $this->click('[id="add-' . $messagesSelector . '-' . $type . '"]');
+        $selector = "$messagesSelector .messages.messages--$type";
+        $msg_element = $web_assert->waitForElementVisible('css', $selector);
+        $this->assertNotEmpty($msg_element, "Message element visible: $selector");
+        $web_assert->elementContains('css', $selector, "This is a message of the type, $type. You be the the judge of its importance.");
+        $current_messages[$selector] = "This is a message of the type, $type. You be the the judge of its importance.";
+        $this->assertCurrentMessages($current_messages, $messagesSelector);
+      }
+      // Remove messages 1 by 1 and confirm the messages are expected.
+      foreach (JsMessageTestCases::getTypes() as $type) {
+        $this->click('[id="remove-' . $messagesSelector . '-' . $type . '"]');
+        $selector = "$messagesSelector .messages.messages--$type";
+        // The message for this selector should not be on the page.
+        unset($current_messages[$selector]);
+        $this->assertCurrentMessages($current_messages, $messagesSelector);
+      }
+    }
+
+    $messagesSelector = JsMessageTestCases::getMessagesSelectors()[0];
+    $current_messages = [];
+    $types = JsMessageTestCases::getTypes();
+    $nb_messages = count($types) * 2;
+    for ($i = 0; $i < $nb_messages; $i++) {
+      $current_messages[] = "This is message number $i of the type, {$types[$i % count($types)]}. You be the the judge of its importance.";
+    }
+    // Test adding multiple messages at once.
+    // @see processMessages()
+    $this->click('[id="add-multiple"]');
+    $this->assertCurrentMessages($current_messages, $messagesSelector);
+    $this->click('[id="remove-multiple"]');
+    $this->assertCurrentMessages([], $messagesSelector);
+
+    $current_messages = [];
+    for ($i = 0; $i < $nb_messages; $i++) {
+      $current_messages[] = "Msg-$i";
+    }
+    // The last message is of a different type and shouldn't get cleared.
+    $last_message = 'Msg-' . count($current_messages);
+    $current_messages[] = $last_message;
+    $this->click('[id="add-multiple-error"]');
+    $this->assertCurrentMessages($current_messages, $messagesSelector);
+    $this->click('[id="remove-type"]');
+    $this->assertCurrentMessages([$last_message], $messagesSelector);
+    $this->click('[id="clear-all"]');
+    $this->assertCurrentMessages([], $messagesSelector);
+
+    // Confirm that when adding a message with an "id" specified but no status
+    // that it receives the default status.
+    $this->click('[id="id-no-status"]');
+    $no_status_msg = 'Msg-id-no-status';
+    $this->assertCurrentMessages([$no_status_msg], $messagesSelector);
+    $web_assert->elementTextContains('css', "$messagesSelector .messages--status[data-drupal-message-id=\"my-special-id\"]", $no_status_msg);
+
+  }
+
+  /**
+   * Asserts that currently shown messages match expected messages.
+   *
+   * @param array $expected_messages
+   *   Expected messages.
+   * @param string $messagesSelector
+   *   The css selector for the containing messages element.
+   */
+  protected function assertCurrentMessages(array $expected_messages, $messagesSelector) {
+    $expected_messages = array_values($expected_messages);
+    $current_messages = [];
+    if ($message_divs = $this->getSession()->getPage()->findAll('css', "$messagesSelector .messages")) {
+      foreach ($message_divs as $message_div) {
+        /** @var \Behat\Mink\Element\NodeElement $message_div */
+        $current_messages[] = $message_div->getText();
+      }
+    }
+    $this->assertEquals($expected_messages, $current_messages);
+  }
+
+}
diff --git a/core/themes/bartik/css/components/messages.css b/core/themes/bartik/css/components/messages.css
index 15a550d0b96484fc69a96b5f7aa740b67e94b405..7018da3a00fb21190aa929b9be0581ccc4584369 100644
--- a/core/themes/bartik/css/components/messages.css
+++ b/core/themes/bartik/css/components/messages.css
@@ -4,10 +4,15 @@
  */
 
 .messages__wrapper {
-  padding: 20px 0 5px 8px;
+  padding: 0 0 0 8px;
   font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-  margin: 8px 0;
 }
 [dir="rtl"] .messages__wrapper {
-  padding: 20px 8px 5px 0;
+  padding: 0 8px 0 0;
+}
+.messages:first-child {
+  margin-top: 28px;
+}
+.messages:last-child {
+  margin-bottom: 13px;
 }
diff --git a/core/themes/classy/templates/misc/status-messages.html.twig b/core/themes/classy/templates/misc/status-messages.html.twig
index 2115b76f536c6e93c1acaf338b654e55f985095b..7dda6c040c49d7fc9bde68e76182fc5487183c4f 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -19,6 +19,7 @@
  *   - class: HTML classes.
  */
 #}
+<div data-drupal-messages>
 {% block messages %}
 {% for type, messages in message_list %}
   {%
@@ -51,3 +52,4 @@
   {% set attributes = attributes.removeClass(classes) %}
 {% endfor %}
 {% endblock messages %}
+</div>
diff --git a/core/themes/stable/templates/misc/status-messages.html.twig b/core/themes/stable/templates/misc/status-messages.html.twig
index 41d3fbd318ae4bfc788146f1090e879859e6828f..969631d2d06eaffc1289c0ac682c6196e9576a55 100644
--- a/core/themes/stable/templates/misc/status-messages.html.twig
+++ b/core/themes/stable/templates/misc/status-messages.html.twig
@@ -19,6 +19,7 @@
  *   - class: HTML classes.
  */
 #}
+<div data-drupal-messages>
 {% for type, messages in message_list %}
   <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes|without('role', 'aria-label') }}>
     {% if type == 'error' %}
@@ -41,3 +42,4 @@
     {% endif %}
   </div>
 {% endfor %}
+</div>