diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 5da51e8..cc3fd71 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -233,6 +233,15 @@ drupal.machine-name:
     - core/drupalSettings
     - core/drupal.form
 
+drupal.message:
+  version: VERSION
+  js:
+    misc/message.js: {}
+  dependencies:
+    - core/jquery
+    - core/drupal
+    - core/drupal.debounce
+
 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 530457f..cd3bf52 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 acf850a..7425a43 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.
    *
@@ -94,27 +101,23 @@
    * messages. These messages are then joined and append to the aria-live region
    * as one text node.
    *
-   * @param {string} text
+   * @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);
+    }
   };
 }(Drupal, Drupal.debounce));
diff --git a/core/misc/message.js b/core/misc/message.js
new file mode 100644
index 0000000..9505ad3
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,109 @@
+/**
+ * @file
+ * Message API.
+ */
+(function ($, Drupal, debounce) {
+
+  'use strict';
+
+  var messages = [];
+  var messagesElement;
+  var debouncedProcessMessages;
+  var messageWrapperSelector = '[data-drupal-messages]';
+
+  /**
+   * Builds a div element with the aria-live attribute and attaches it to the DOM.
+   */
+  Drupal.behaviors.drupalMessage = {
+    attach: function () {
+      if (!messagesElement) {
+        var $messagesWrapper = $(messageWrapperSelector);
+        if (!$messagesWrapper.length) {
+          throw new Error(Drupal.t('There is no element with a [data-drupal-messages] attribute'));
+        }
+        messagesElement = $messagesWrapper[0];
+      }
+    }
+  };
+
+  // Debounce the function in case Drupal.message() is used in a loop.
+  debouncedProcessMessages = debounce(processMessages, 100);
+
+  /**
+   * Displays all queued messages in one pass instead of one after the other.
+   * @see debouncedProcessMessages
+   */
+  function processMessages() {
+    var text = [];
+
+    messages.reverse().forEach(function (message) {
+      text.unshift(Drupal.theme('message', message));
+    });
+    if (text.length) {
+      messagesElement.innerHTML += text.join('');
+    }
+    // Reset the messages array after the messages are processed.
+    messages = [];
+
+  }
+
+  Drupal.message = {};
+
+  /**
+   * Displays a message on the page.
+   *
+   * @param {string} message
+   *   The message to display
+   * @param {string} type
+   *   Message type, can be either 'status', 'error' or 'warning'.
+   *   Default to 'status'
+   * @param {string} context
+   *   The context of the message, used for removing messages again.
+   */
+  Drupal.message.add = function (message, type, context) {
+    if (typeof message === 'string') {
+      // Save the text and priority into a closure variable. Multiple simultaneous
+      // announcements will be concatenated and read in sequence.
+      messages.push({
+        context: context || 'default',
+        text: message,
+        type: type || 'status'
+      });
+      debouncedProcessMessages(messages);
+    }
+  };
+
+  /**
+   * Removes messages from the page.
+   *
+   * @param {string} context
+   *   The message context to remove
+   * @param {string} type
+   *   Message type, can be either 'status', 'error' or 'warning'.
+   *   Default to 'status'
+   */
+  Drupal.message.remove = function (context, type) {
+    context = context || 'default';
+    type = type || 'status';
+    $('.messages--' + type + '.js-messages-context--' + context).remove();
+  };
+
+  /**
+   * 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 {string} message.context
+   *   The message context.
+   * @return {string}
+   *   A string representing a DOM fragment.
+   */
+  Drupal.theme.message = function (message) {
+    return '<div class="messages messages--' + message.type + ' js-messages js-messages-context--' + message.context + '">' + message.text + '</div>';
+  };
+
+}(jQuery, Drupal, Drupal.debounce));
diff --git a/core/modules/system/templates/status-messages.html.twig b/core/modules/system/templates/status-messages.html.twig
index 73bd9b7..e8f64e0 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 0000000..576916f
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
@@ -0,0 +1,40 @@
+/**
+ * @file
+ *  Testing behavior for JSMessageTest.
+ */
+
+(function ($, message) {
+
+  '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) {
+      $('.show-link').once('show-msg').on('click', function (e) {
+        e.preventDefault();
+        var type = e.currentTarget.getAttribute('data-type');
+        var context = e.currentTarget.getAttribute('data-context');
+        message.add('Msg-' + type + '-' + context, type, context);
+      });
+      $('.remove-link').once('remove-msg').on('click', function (e) {
+        e.preventDefault();
+        var type = e.currentTarget.getAttribute('data-type');
+        var context = e.currentTarget.getAttribute('data-context');
+        message.remove(context, type);
+      });
+      $('.show-multiple').once('show-msg').on('click', function (e) {
+        e.preventDefault();
+        for (var i = 0; i < 10; i++) {
+          message.add('Msg-' + i, 'default', 'context-' + i);
+        }
+
+      });
+    }
+  };
+
+})(jQuery, Drupal.message);
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 0000000..e8bc73b
--- /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 0000000..53da0f9
--- /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 0000000..4c325e8
--- /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 0000000..ce25516
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\js_message_test\Controller;
+
+use Drupal\Core\Url;
+
+/**
+ * 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 (['context1', 'context2'] as $context) {
+      foreach (['status', 'error', 'warning'] as $type) {
+        $links["show-$context-$type"] = [
+          'title' => "Show-$context-$type",
+          'url' => Url::fromRoute('js_message_test.links'),
+          'attributes' => [
+            'id' => "show-$context-$type",
+            'data-context' => $context,
+            'data-type' => $type,
+            'class' => ['show-link'],
+          ],
+        ];
+        $links["remove-$context-$type"] = [
+          'title' => "Remove-$context-$type",
+          'url' => Url::fromRoute('js_message_test.links'),
+          'attributes' => [
+            'id' => "remove-$context-$type",
+            'data-context' => $context,
+            'data-type' => $type,
+            'class' => ['remove-link'],
+          ],
+        ];
+      }
+    }
+    $links['show-multi'] = [
+      'title' => "Show Multiple",
+      'url' => Url::fromRoute('js_message_test.links'),
+      'attributes' => [
+        'class' => ['show-multiple'],
+      ],
+    ];
+    return [
+      '#theme' => 'links',
+      '#links' => $links,
+      '#attached' => [
+        'library' => [
+          'js_message_test/show_message',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
new file mode 100644
index 0000000..1893879
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Core;
+
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+
+/**
+ * Tests core/drupal.messages library.
+ *
+ * @group Javascript
+ */
+class JsMessageTest extends JavascriptTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['js_message_test'];
+
+  /**
+   * The possible message contexts.
+   *
+   * @var array
+   *
+   * @see \Drupal\js_message_test\Controller\JSMessageTestController::messageLinks
+   */
+  protected $msgContexts = ['context1', 'context2'];
+
+  /**
+   * The possible message types.
+   *
+   * @var array
+   *
+   * @see \Drupal\js_message_test\Controller\JSMessageTestController::messageLinks
+   */
+  protected $msgTypes = ['status', 'error', 'warning'];
+
+  /**
+   * Test click on links to show messages and remove messages.
+   */
+  public function testAddRemoveMessages() {
+    $web_assert = $this->assertSession();
+    $this->drupalGet('js_message_test_link');
+    $web_assert->elementExists('css', '[data-drupal-messages]');
+
+    foreach ($this->msgContexts as $context) {
+      foreach ($this->msgTypes as $type) {
+        $this->clickLink("Show-$context-$type");
+        $selector = ".messages.messages--$type.js-messages.js-messages-context--$context";
+        $msg_element = $web_assert->waitForElementVisible('css', $selector);
+        $this->assertNotEmpty($msg_element, "Message element visible: $selector");
+        $web_assert->elementContains('css', $selector, "Msg-$type-$context");
+        // Click all remove links except the one that will remove the message.
+        $this->clickAllRemoveLinksExcept($context, $type);
+        // Confirm the message was not removed.
+        $web_assert->elementContains('css', $selector, "Msg-$type-$context");
+        // Click the remove links that should remove the message.
+        $this->clickLink("Remove-$context-$type");
+        $web_assert->elementNotExists('css', $selector);
+      }
+    }
+
+    // Test adding multiple messages at once.
+    // @see processMessages()
+    $this->clickLink('Show Multiple');
+    $message_number = 0;
+    $last_msg_element = $web_assert->waitForElementVisible('css', '.messages.messages--default.js-messages.js-messages-context--context-9');
+    $this->assertNotEmpty($last_msg_element, "Last message element visible.");
+    $message_divs = $this->getSession()->getPage()->findAll('css', '.messages');
+    $this->assertEquals(10, count($message_divs), 'All messages shown');
+
+    foreach ($message_divs as $message_div) {
+      /** @var \Behat\Mink\Element\NodeElement $message_div */
+      $this->assertEquals("Msg-$message_number", $message_div->getText());
+      $message_number++;
+    }
+  }
+
+  /**
+   * Clicks all remove message links except for combination specified.
+   *
+   * @param string $exclude_context
+   *   The message context to exclude.
+   * @param string $exclude_type
+   *   The message type to exclude.
+   */
+  protected function clickAllRemoveLinksExcept($exclude_context, $exclude_type) {
+    foreach ($this->msgContexts as $context) {
+      foreach ($this->msgTypes as $type) {
+        if ($context !== $exclude_context || $type !== $exclude_type) {
+          $this->clickLink("Remove-$context-$type");
+        }
+      }
+    }
+  }
+
+}
diff --git a/core/themes/classy/templates/misc/status-messages.html.twig b/core/themes/classy/templates/misc/status-messages.html.twig
index bc8fd10..0d059f3 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -21,7 +21,7 @@
  *   - class: HTML classes.
  */
 #}
-{{ attach_library('classy/messages') }}
+<div data-drupal-messages>
 {% block messages %}
 {% for type, messages in message_list %}
   {%
@@ -54,3 +54,4 @@
   {% set attributes = attributes.removeClass(classes) %}
 {% endfor %}
 {% endblock messages %}
+</div>
