diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 5da51e8ae5..70425b85cb 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..cd3bf525af 100644
--- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php
+++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
@@ -75,19 +75,17 @@ public static function generatePlaceholder(array $element) {
   public static function renderMessages($type) {
     $render = [];
     $messages = drupal_get_messages($type);
-    if ($messages) {
-      // Render the messages.
-      $render = [
-        '#theme' => 'status_messages',
-        // @todo Improve when https://www.drupal.org/node/2278383 lands.
-        '#message_list' => $messages,
-        '#status_headings' => [
-          'status' => t('Status message'),
-          'error' => t('Error message'),
-          'warning' => t('Warning message'),
-        ],
-      ];
-    }
+    // Render the messages.
+    $render = [
+      '#theme' => 'status_messages',
+      // @todo Improve when https://www.drupal.org/node/2278383 lands.
+      '#message_list' => $messages,
+      '#status_headings' => [
+        'status' => t('Status message'),
+        'error' => t('Error message'),
+        'warning' => t('Warning message'),
+      ],
+    ];
     return $render;
   }
 
diff --git a/core/misc/announce.js b/core/misc/announce.js
index acf850a641..cd1ee5bb1a 100644
--- a/core/misc/announce.js
+++ b/core/misc/announce.js
@@ -50,21 +50,23 @@
   /**
    * Concatenates announcements to a single string; appends to the live region.
    */
-  function announce() {
+  function processAnnounce() {
     var text = [];
     var priority = 'polite';
     var announcement;
 
-    // Create an array of announcement strings to be joined and appended to the
-    // aria live region.
-    var il = announcements.length;
-    for (var i = 0; i < il; i++) {
-      announcement = announcements.pop();
-      text.unshift(announcement.text);
-      // If any of the announcements has a priority of assertive then the group
-      // of joined announcements will have this priority.
-      if (announcement.priority === 'assertive') {
-        priority = 'assertive';
+    if (announcements.length) {
+      // Create an array of announcement strings to be joined and appended to
+      // the aria live region.
+      var il = announcements.length;
+      for (var i = 0; i < il; i++) {
+        announcement = announcements.pop();
+        text.unshift(announcement.text);
+        // If any of the announcements has a priority of assertive then the
+        // group of joined announcements will have this priority.
+        if (announcement.priority === 'assertive') {
+          priority = 'assertive';
+        }
       }
     }
 
@@ -81,8 +83,13 @@
       // The live text area is updated. Allow the AT to announce the text.
       liveElement.setAttribute('aria-busy', 'false');
     }
+
   }
 
+  // 200 ms is right at the cusp where humans notice a pause, so we will wait
+  // at most this much time before the set of queued announcements is read.
+  var debouncedProcessAnnounce = debounce(processAnnounce, 200);
+
   /**
    * Triggers audio UAs to read the supplied text.
    *
@@ -96,25 +103,24 @@
    *
    * @param {string} text
    *   A string to be read by the UA.
-   * @param {string} [priority='polite']
+   * @param {string} priority
    *   A string to indicate the priority of the message. Can be either
    *   'polite' or 'assertive'.
    *
-   * @return {function}
-   *   The return of the call to debounce.
-   *
    * @see http://www.w3.org/WAI/PF/aria-practices/#liveprops
    */
   Drupal.announce = function (text, priority) {
-    // Save the text and priority into a closure variable. Multiple simultaneous
-    // announcements will be concatenated and read in sequence.
-    announcements.push({
-      text: text,
-      priority: priority
-    });
-    // Immediately invoke the function that debounce returns. 200 ms is right at
-    // the cusp where humans notice a pause, so we will wait
-    // at most this much time before the set of queued announcements is read.
-    return (debounce(announce, 200)());
+    if (typeof text === 'string') {
+      // Save the text and priority into a closure variable. Multiple
+      // simultaneous announcements will be concatenated and read in sequence.
+      announcements.push({
+        text: text,
+        priority: priority
+      });
+      debouncedProcessAnnounce(announcements);
+    }
+    else {
+      throw new Error(Drupal.t('"text" passed Drupal.announce must be a string.'));
+    }
   };
 }(Drupal, Drupal.debounce));
diff --git a/core/misc/message.js b/core/misc/message.js
new file mode 100644
index 0000000000..b1c39ea471
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,157 @@
+/**
+ * @file
+ * Message API.
+ */
+(function (Drupal) {
+
+  'use strict';
+
+  var defaultMessageWrapperSelector = '[data-drupal-messages]';
+
+  /**
+   * @typedef {object} Drupal.message~messageDefinition
+   *
+   * @prop {HTMLElement} element
+   *   DOM element of the messages wrapper.
+   */
+
+  /**
+   * woot
+   *
+   * @param {HTMLElement?} messageWrapper
+   *   The zone where to add messages.
+   *
+   * @return {Drupal.message~messageDefinition}
+   *   Object to add and remove messages.
+   */
+  Drupal.message = function (messageWrapper) {
+    if (typeof messageWrapper === 'string') {
+      throw new Error(Drupal.t('Drupal.message() expect an HTMLElement as parameter.'));
+    }
+    if (!messageWrapper) {
+      messageWrapper = document.querySelector(defaultMessageWrapperSelector);
+      if (!messageWrapper) {
+        throw new Error(Drupal.t('There is no @element on the page.', {'%element': defaultMessageWrapperSelector}));
+      }
+    }
+
+    /**
+     * Displays a message on the page.
+     *
+     * @name Drupal.message~messageDefinition.add
+     *
+     * @param {string} message
+     *   The message to display
+     * @param {string} [type=status]
+     *   Message type, can be either 'status', 'error' or 'warning'.
+     * @param {object} [options]
+     *   The context of the message, used for removing messages again.
+     *
+     * @return {string}
+     *   Index of message.
+     */
+    function messageAdd(message, type, options) {
+      if (typeof message !== 'string') {
+        throw new Error('Message must be a string.');
+      }
+      type = type || 'status';
+      options = options || {};
+      // Send message to screenreader.
+      announce(message, type, options);
+      // Generate a unique key to allow message deletion.
+      options.index = Math.random().toFixed(15).replace('0.', '');
+      this.element.innerHTML += Drupal.theme('message', {text: message, type: type}, options);
+
+      return options.index;
+    }
+
+    /**
+     * Removes messages from the page.
+     *
+     * @name Drupal.message~messageDefinition.remove
+     *
+     * @param {Number|String|Array.<string|number>} [messages]
+     *   Index of the message to remove, as returned by
+     *   {@link Drupal.message~messageDefinition.add}, a number
+     *   corresponding to the CSS index of the element, or an
+     *   array containing a combination of the previous two types.
+     *
+     * @return {number}
+     *  Number of removed messages.
+     */
+    function messageRemove(messages) {
+      if (!messages) {
+        throw new Error(Drupal.t('Object.message() expect a message to remove.'));
+      }
+
+      var removeSelectors = (messages instanceof Array ? messages : [messages])
+        .map(function (messageIndex) {
+          return '[data-drupal-message="' + messageIndex + '"]';
+        });
+
+      var remove = this.element.querySelectorAll(removeSelectors.join(', '));
+      var length = remove.length;
+      for (var i = 0; i < length; i += 1) {
+        this.element.removeChild(remove[i]);
+      }
+
+      return length;
+    }
+
+    return {
+      element: messageWrapper,
+      add: messageAdd,
+      remove: messageRemove
+    };
+  };
+
+  /**
+   * Helper to call Drupal.announce() with the right parameters.
+   *
+   * @param {string} message
+   *   Displayed message.
+   * @param {string} type
+   *   Message type, can be either 'status', 'error' or 'warning'.
+   * @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.annonce() this should be `''`.
+   * @param {string} [options.priority]
+   *   Priority of the message for Drupal.announce().
+   */
+  function announce(message, type, options) {
+    // Check this. Might be too much.
+    if (!options.priority && type !== 'status') {
+      options.priority = 'assertive';
+    }
+    // If screenreader message is not disabled announce screenreader-specific
+    // text or fallback to the displayed message.
+    if (options.announce !== '') {
+      Drupal.announce(options.announce || message, options.priority);
+    }
+  }
+
+  /**
+   * Theme function for a message.
+   *
+   * @param {object} message
+   *   The message object.
+   * @param {string} message.text
+   *   The message text.
+   * @param {string} message.type
+   *   The message type.
+   * @param {object} [options]
+   *   The message context.
+   * @param {string} options.index
+   *   Index of the message, for reference.
+   *
+   * @return {string}
+   *   A string representing a DOM fragment.
+   */
+  Drupal.theme.message = function (message, options) {
+    return '<div class="messages messages--' + message.type + '" ' +
+      'data-drupal-message="' + options.index + '"> ' + message.text + '</div>';
+  };
+
+}(Drupal));
diff --git a/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
index 69ec86fdc2..74816999eb 100644
--- a/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
+++ b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
@@ -110,11 +110,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[0]&token=_HAdUpwWmet0TOTe2PSiJuMntExoshbm1kh2wQzzzAA"]',
-          'data' => "\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    ",
+          'data' => "\n" . '<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 = '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n" . "\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    \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";
     }
 
 
diff --git a/core/modules/system/src/Tests/JsMessageTestCases.php b/core/modules/system/src/Tests/JsMessageTestCases.php
new file mode 100644
index 0000000000..20d2e7630b
--- /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]', '[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..6fd9fcaf50
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
@@ -0,0 +1,64 @@
+/**
+ * @file
+ *  Testing behavior for JSMessageTest.
+ */
+
+(function ($) {
+
+  'use strict';
+
+  /**
+   * @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) {
+      var messageObjects = {};
+      var messageIndexes = {};
+
+      $('.show-link').once('show-msg').on('click', function (e) {
+
+        e.preventDefault();
+        var type = e.currentTarget.getAttribute('data-type');
+        messageIndexes[type] = getMessageObject(e).add('Msg-' + type, type);
+      });
+      $('.remove-link').once('remove-msg').on('click', function (e) {
+        e.preventDefault();
+        var type = e.currentTarget.getAttribute('data-type');
+        getMessageObject(e).remove(messageIndexes[type]);
+      });
+      $('.show-multiple').once('show-msg').on('click', function (e) {
+        e.preventDefault();
+        for (var i = 0; i < 10; i++) {
+          messageIndexes[i] = getMessageObject(e).add('Msg-' + i, 'status');
+        }
+
+      });
+      $('.remove-multiple').once('remove-msg').on('click', function (e) {
+        e.preventDefault();
+        for (var i = 0; i < 10; i++) {
+          getMessageObject(e).remove(messageIndexes[i]);
+        }
+      });
+
+      /**
+       * Gets message object for the click event.
+       *
+       * @param {jQuery.Event} e
+       *   The click event.
+       * @return {Drupal.message~messageDefinition}
+       *  The message object for correct div.
+       */
+      function getMessageObject(e) {
+        var divSelector = e.currentTarget.getAttribute('data-selector');
+        if (!messageObjects.hasOwnProperty(divSelector)) {
+          messageObjects[divSelector] = Drupal.message(document.querySelector(divSelector));
+        }
+        return messageObjects[divSelector];
+      }
+    }
+  };
+
+})(jQuery);
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..53da0f945e
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml
@@ -0,0 +1,7 @@
+show_message:
+  version: VERSION
+  js:
+    js/js_message_test.js: {}
+  dependencies:
+    - 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..462f2c25a4
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\js_message_test\Controller;
+
+use Drupal\Core\Url;
+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() {
+    $links = [];
+    foreach (JsMessageTestCases::getMessagesSelectors() as $messagesSelector) {
+      foreach (JsMessageTestCases::getTypes() as $type) {
+        $links["show-$messagesSelector-$type"] = [
+          'title' => "Show-$messagesSelector-$type",
+          'url' => Url::fromRoute('js_message_test.links'),
+          'attributes' => [
+            'id' => "show-$messagesSelector-$type",
+            'data-type' => $type,
+            'data-selector' => $messagesSelector,
+            'class' => ['show-link'],
+          ],
+        ];
+        $links["remove-$messagesSelector-$type"] = [
+          'title' => "Remove-$messagesSelector-$type",
+          'url' => Url::fromRoute('js_message_test.links'),
+          'attributes' => [
+            'id' => "remove-$messagesSelector-$type",
+            'data-type' => $type,
+            'data-selector' => $messagesSelector,
+            'class' => ['remove-link'],
+          ],
+        ];
+      }
+    }
+
+    $links['show-multi'] = [
+      'title' => "Show Multiple",
+      'url' => Url::fromRoute('js_message_test.links'),
+      'attributes' => [
+        'class' => ['show-multiple'],
+      ],
+    ];
+    $links['remove-multi'] = [
+      'title' => "Remove Multiple",
+      'url' => Url::fromRoute('js_message_test.links'),
+      'attributes' => [
+        'class' => ['remove-multiple'],
+      ],
+    ];
+    return [
+      '#theme' => 'links',
+      '#links' => $links,
+      '#attached' => [
+        'library' => [
+          'js_message_test/show_message',
+        ],
+      ],
+    ];
+  }
+
+}
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..6e94958b74
--- /dev/null
+++ b/core/modules/system/tests/themes/test_messages/templates/status-messages.html.twig
@@ -0,0 +1,42 @@
+{#
+/**
+ * @file
+ * Test templates file with extra messages div.
+ */
+#}
+{{ attach_library('classy/messages') }}
+<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..d779adf63d
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
@@ -0,0 +1,93 @@
+<?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->clickLink("Show-$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] = "Msg-$type";
+        $this->assertCurrentMessages($current_messages);
+      }
+      // Remove messages 1 by 1 and confirm the messages are expected.
+      foreach (JsMessageTestCases::getTypes() as $type) {
+        $this->clickLink("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);
+      }
+    }
+
+    // Test adding multiple messages at once.
+    // @see processMessages()
+    $this->clickLink('Show Multiple');
+
+    $current_messages = [];
+    for ($i = 0; $i < 10; $i++) {
+      $current_messages[] = "Msg-$i";
+    }
+    $this->assertCurrentMessages($current_messages);
+    $this->clickLink('Remove Multiple');
+    $this->assertCurrentMessages([]);
+  }
+
+  /**
+   * Asserts that currently shown messages match expected messages.
+   *
+   * @param array $expected_messages
+   *   Expected messages.
+   */
+  protected function assertCurrentMessages(array $expected_messages) {
+    $expected_messages = array_values($expected_messages);
+    $current_messages = [];
+    if ($message_divs = $this->getSession()->getPage()->findAll('css', '.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 bc8fd106c2..6985ee7594 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -22,6 +22,7 @@
  */
 #}
 {{ attach_library('classy/messages') }}
+<div data-drupal-messages>
 {% block messages %}
 {% for type, messages in message_list %}
   {%
@@ -54,3 +55,4 @@
   {% set attributes = attributes.removeClass(classes) %}
 {% endfor %}
 {% endblock messages %}
+</div>
