diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 159182aeb6..2e3912a1c3 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 530457fd90..c4220736bc 100644
--- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php
+++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
@@ -73,7 +73,6 @@ public static function generatePlaceholder(array $element) {
    * @see drupal_get_messages()
    */
   public static function renderMessages($type) {
-    $render = [];
     $messages = drupal_get_messages($type);
     if ($messages) {
       // Render the messages.
@@ -88,6 +87,13 @@ public static function renderMessages($type) {
         ],
       ];
     }
+    else {
+      // Provide empty div for Javascript to add messages to.
+      $render = [
+        '#markup' => '<div data-drupal-messages></div>',
+      ];
+    }
+
     return $render;
   }
 
diff --git a/core/misc/message.es6.js b/core/misc/message.es6.js
new file mode 100644
index 0000000000..65486b54aa
--- /dev/null
+++ b/core/misc/message.es6.js
@@ -0,0 +1,267 @@
+/**
+ * @file
+ * Message API.
+ */
+((Drupal) => {
+  /**
+   * @typedef {object} Drupal.message~messageDefinition
+   */
+
+  /**
+   * Constructs a new instance of the Drupal.message object.
+   *
+   * 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}
+   *   Object to add and remove messages.
+   */
+
+  Drupal.message = class {
+    constructor(messageWrapper = Drupal.message.defaultWrapper()) {
+      this.messageWrapper = messageWrapper;
+    }
+
+    /**
+     * Attempt to determine the default location for
+     * inserting JavaScript messages.
+     *
+     * @return {HTMLElement}
+     *   The default destination for JavaScript messages.
+     */
+    static defaultWrapper() {
+      const wrapper = document.querySelector('[data-drupal-messages]');
+      if (!wrapper) {
+        throw new Error(Drupal.t('There is no @element on the page.', { '@element': '[data-drupal-messages]' }));
+      }
+      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 getMessageTypes() {
+      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, used for removing messages again.
+     * @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 a sort of tag for message deletion.
+     * @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 `''`.
+     * @param {string} [options.priority]
+     *   Priority of the message for Drupal.announce().
+     *
+     * @return {string}
+     *   ID of message.
+     */
+    add(message, options = {}) {
+      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 unique key to
+       * allow message deletion.
+       */
+      options.id = options.id ?
+        String(options.id) :
+        `${options.type}-${Math.random().toFixed(15).replace('0.', '')}`;
+      this.messageWrapper.appendChild(Drupal.theme('message', { text: message }, options));
+
+      return options.id;
+    }
+
+    /**
+     * Select a set of messages based on index.
+     *
+     * @name Drupal.message~messageDefinition.select
+     *
+     * @param {string|Array.<string>} index
+     *   The message index to delete from the area.
+     *
+     * @return {NodeList|Array}
+     *   Elements found.
+     */
+    select(index) {
+      // When there are nothing to select, return an empty list.
+      if (!index || (Array.isArray(index) && index.length === 0)) {
+        return [];
+      }
+
+      // Construct an array of selectors based on the available message index(s).
+      const selectors = (Array.isArray(index) ? index : [index])
+        .map(currentIndex => `[data-drupal-message-id^="${currentIndex}"]`);
+
+      return this.messageWrapper.querySelectorAll(selectors.join(','));
+    }
+
+    /**
+     * Helper to remove elements.
+     *
+     * @param {NodeList|Array.<HTMLElement>} elements
+     *   DOM Nodes to be removed.
+     *
+     * @return {number}
+     *  Number of removed nodes.
+     */
+    removeElements(elements) {
+      if (!elements || !elements.length) {
+        return 0;
+      }
+
+      const length = elements.length;
+      for (let i = 0; i < length; i++) {
+        this.messageWrapper.removeChild(elements[i]);
+      }
+      return length;
+    }
+
+    /**
+     * Removes messages from the message area.
+     *
+     * @name Drupal.message~messageDefinition.remove
+     *
+     * @param {string|Array.<string>} ids
+     *   Index of the message to remove, as returned by
+     *   {@link Drupal.message~messageDefinition.add}, or an
+     *   array of indexes.
+     *
+     * @return {number}
+     *  Number of removed messages.
+     */
+    remove(ids) {
+      const messages = this.select(ids);
+      return this.removeElements(messages);
+    }
+
+    /**
+     * Removes all messages from the message area.
+     *
+     * @name Drupal.message~messageDefinition.clear
+     *
+     * @return {number}
+     *  Number of removed messages.
+     */
+    clear() {
+      const messages = this.messageWrapper.querySelectorAll('[data-drupal-message-id]');
+      return this.removeElements(messages);
+    }
+
+    /**
+     * 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 }, options) => {
+    const messagesTypes = Drupal.message.getMessageTypes();
+    const messageWraper = document.createElement('div');
+    const messageText = document.createElement('h2');
+    messageText.setAttribute('class', 'visually-hidden');
+
+    messageWraper.setAttribute('class', `messages messages--${options.type}`);
+    messageWraper.setAttribute('role', 'contentinfo');
+    messageWraper.setAttribute('data-drupal-message-id', options.id);
+    messageWraper.setAttribute('data-drupal-message-type', options.type);
+    if (options.type in messagesTypes) {
+      messageWraper.setAttribute('aria-label', messagesTypes[options.type]);
+      messageText.innerHTML = messagesTypes[options.type];
+    }
+
+    // Alerts have a different HTML structure.
+    if (options.type === 'error') {
+      messageText.setAttribute('role', 'alert');
+    }
+    messageWraper.innerHTML = ` ${text}`;
+    messageWraper.insertAdjacentElement('afterbegin', messageText);
+
+    return messageWraper;
+  };
+})(Drupal);
diff --git a/core/misc/message.js b/core/misc/message.js
new file mode 100644
index 0000000000..12324585c0
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,147 @@
+/**
+* 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] : Drupal.message.defaultWrapper();
+
+      _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 (!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.', '');
+        this.messageWrapper.appendChild(Drupal.theme('message', { text: message }, options));
+
+        return options.id;
+      }
+    }, {
+      key: 'select',
+      value: function select(index) {
+        if (!index || Array.isArray(index) && index.length === 0) {
+          return [];
+        }
+
+        var selectors = (Array.isArray(index) ? index : [index]).map(function (currentIndex) {
+          return '[data-drupal-message-id^="' + currentIndex + '"]';
+        });
+
+        return this.messageWrapper.querySelectorAll(selectors.join(','));
+      }
+    }, {
+      key: 'removeElements',
+      value: function removeElements(elements) {
+        if (!elements || !elements.length) {
+          return 0;
+        }
+
+        var length = elements.length;
+        for (var i = 0; i < length; i++) {
+          this.messageWrapper.removeChild(elements[i]);
+        }
+        return length;
+      }
+    }, {
+      key: 'remove',
+      value: function remove(ids) {
+        var messages = this.select(ids);
+        return this.removeElements(messages);
+      }
+    }, {
+      key: 'clear',
+      value: function clear() {
+        var messages = this.messageWrapper.querySelectorAll('[data-drupal-message-id]');
+        return this.removeElements(messages);
+      }
+    }], [{
+      key: 'defaultWrapper',
+      value: function defaultWrapper() {
+        var wrapper = document.querySelector('[data-drupal-messages]');
+        if (!wrapper) {
+          throw new Error(Drupal.t('There is no @element on the page.', { '@element': '[data-drupal-messages]' }));
+        }
+        return wrapper.innerHTML === '' ? Drupal.message.messageInternalWrapper(wrapper) : wrapper.firstElementChild;
+      }
+    }, {
+      key: 'getMessageTypes',
+      value: function getMessageTypes() {
+        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, options) {
+    var text = _ref.text;
+
+    var messagesTypes = Drupal.message.getMessageTypes();
+    var messageWraper = document.createElement('div');
+    var messageText = document.createElement('h2');
+    messageText.setAttribute('class', 'visually-hidden');
+
+    messageWraper.setAttribute('class', 'messages messages--' + options.type);
+    messageWraper.setAttribute('role', 'contentinfo');
+    messageWraper.setAttribute('data-drupal-message-id', options.id);
+    messageWraper.setAttribute('data-drupal-message-type', options.type);
+    if (options.type in messagesTypes) {
+      messageWraper.setAttribute('aria-label', messagesTypes[options.type]);
+      messageText.innerHTML = messagesTypes[options.type];
+    }
+
+    if (options.type === 'error') {
+      messageText.setAttribute('role', 'alert');
+    }
+    messageWraper.innerHTML = ' ' + text;
+    messageWraper.insertAdjacentElement('afterbegin', messageText);
+
+    return messageWraper;
+  };
+})(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 e3da529ea4..524680a235 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>' . "\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\n";
     }
 
     // 2. Real-world example of HTML attribute value placeholder: form action.
diff --git a/core/modules/system/src/Tests/JsMessageTestCases.php b/core/modules/system/src/Tests/JsMessageTestCases.php
new file mode 100644
index 0000000000..05e1040dd3
--- /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 73bd9b7446..e8f64e0ac5 100644
--- a/core/modules/system/templates/status-messages.html.twig
+++ b/core/modules/system/templates/status-messages.html.twig
@@ -23,25 +23,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.js b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
new file mode 100644
index 0000000000..db4c01d0a9
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
@@ -0,0 +1,86 @@
+/**
+ * @file
+ *  Testing behavior for JSMessageTest.
+ */
+
+(function ($, Drupal, drupalSettings) {
+
+  'use strict';
+
+  var messageObjects = {};
+  var messageIndexes = {multiple: []};
+
+  drupalSettings.testMessages.selectors.forEach(function (selector) {
+    messageObjects[selector] = new Drupal.message(selector === '' ? undefined : document.querySelector(selector));
+
+    messageIndexes[selector] = {};
+    drupalSettings.testMessages.types.forEach(function (type) {
+      messageIndexes[selector][type] = [];
+    });
+  });
+
+  var defaultMessageArea = messageObjects[drupalSettings.testMessages.selectors[0]];
+
+  /**
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Add click listeners that show and remove links with context and type.
+   */
+  Drupal.behaviors.js_message_test = {
+    attach: function (context) {
+
+      $('[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');
+        var message = messageObjects[area];
+        var action = $target.attr('data-action');
+
+        if (action === 'add') {
+          messageIndexes[area][type].push(message.add('Msg-' + type, {type: type}));
+        }
+        else if (action === 'remove') {
+          message.remove(messageIndexes[area][type].pop());
+        }
+      });
+
+      $('[data-action="add-multiple"]').once('add-multiple').on('click', function (e) {
+        var types = drupalSettings.testMessages.types;
+        // Add several of different types to make sure message type doesn't
+        // cause issues in the API.
+        for (var i = 0; i < types.length * 2; i++) {
+          messageIndexes.multiple.push(defaultMessageArea.add('Msg-' + i, {type: types[i % types.length]}));
+        }
+      });
+
+      $('[data-action="remove-multiple"]').once('remove-multiple').on('click', function (e) {
+        defaultMessageArea.remove(messageIndexes.multiple);
+        messageIndexes.multiple = [];
+      });
+
+      $('[data-action="add-multiple-error"]').once('add-multiple-error').on('click', function (e) {
+        // Use the same number of elements to facilitate things on the PHP side.
+        var total = drupalSettings.testMessages.types.length * 2;
+        for (var i = 0; i < total; i++) {
+          defaultMessageArea.add('Msg-' + i, {type: 'error'});
+        }
+        defaultMessageArea.add('Msg-' + total, {type: 'status'});
+      });
+
+      $('[data-action="remove-type"]').once('remove-type').on('click', function (e) {
+        defaultMessageArea.remove('error');
+      });
+
+      $('[data-action="clear-all"]').once('clear-all').on('click', function (e) {
+        defaultMessageArea.clear();
+      });
+
+      $('[data-action="id-no-status"]').once('id-no-status').on('click', function (e) {
+        defaultMessageArea.add('Msg-id-no-status', {id: 'my-special-id'});
+      });
+
+    }
+  };
+
+})(jQuery, Drupal, drupalSettings);
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 0000000000..e8bc73b065
--- /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 0000000000..57501a9ce0
--- /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 0000000000..4c325e8461
--- /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 0000000000..57d38284f7
--- /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 0000000000..42f105d3df
--- /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 0000000000..ea88438d98
--- /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/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
new file mode 100644
index 0000000000..ca21be0551
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Core;
+
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+use Drupal\system\Tests\JsMessageTestCases;
+
+/**
+ * Tests core/drupal.messages library.
+ *
+ * @group Javascript
+ */
+class JsMessageTest extends JavascriptTestBase {
+
+  /**
+   * {@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, "Msg-$type");
+        $current_messages[$selector] = ucfirst($type) . " message Msg-$type";
+        $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[] = ucfirst($types[$i % count($types)]) . " message Msg-$i";
+    }
+    // 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[] = "Error message Msg-$i";
+    }
+    // The last message is of a different type and shouldn't get cleared.
+    $last_message = 'Status 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 = 'Status message 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 15a550d0b9..bc9001167d 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;
+}
+.messages__wrapper .messages:first-child {
+  margin-top: 28px;
+}
+.messages__wrapper .messages:last-child {
+  margin-bottom: 13px;
 }
 [dir="rtl"] .messages__wrapper {
-  padding: 20px 8px 5px 0;
+  padding: 0 8px 0 0;
 }
diff --git a/core/themes/bartik/templates/status-messages.html.twig b/core/themes/bartik/templates/status-messages.html.twig
index a4e1e9e240..aa1bedd773 100644
--- a/core/themes/bartik/templates/status-messages.html.twig
+++ b/core/themes/bartik/templates/status-messages.html.twig
@@ -23,7 +23,7 @@
 {% block messages %}
   {% if message_list is not empty %}
     {{ attach_library('bartik/messages') }}
-    <div class="messages__wrapper layout-container">
+    <div class="messages__wrapper layout-container" data-drupal-messages>
       {{ parent() }}
     </div>
   {% endif %}
diff --git a/core/themes/classy/templates/misc/status-messages.html.twig b/core/themes/classy/templates/misc/status-messages.html.twig
index 282df38ef2..0d059f33bb 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -21,6 +21,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/themes/stable/templates/misc/status-messages.html.twig b/core/themes/stable/templates/misc/status-messages.html.twig
index c7119f152c..4a766f3323 100644
--- a/core/themes/stable/templates/misc/status-messages.html.twig
+++ b/core/themes/stable/templates/misc/status-messages.html.twig
@@ -21,6 +21,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' %}
@@ -43,3 +44,4 @@
     {% endif %}
   </div>
 {% endfor %}
+</div>
